A little website that fixes PDFs for you — glue them together, chop them apart, throw pages away, spin them around, number them, or change the words on them.
The best part: it doesn't send your file anywhere. Everything happens right inside your own web browser, like a calculator app that works even when the Wi-Fi is off.
- Explain it like I'm 10
- The nine tools
- How you use it, step by step
- What's inside the box (the files)
- How the code actually works
- The one weird tool: Edit Text
- Running it yourself
- Privacy
- Ideas for making it better
Imagine a PDF is a stack of paper held together with a big paperclip.
Normally, to move the pages around you'd need scissors, tape, and a photocopier. This website is the scissors and tape — except it's on your screen, and nothing gets ruined if you make a mistake, because it always gives you a brand-new stack and leaves your original one completely untouched.
Here's the trick that surprises most people: the website doesn't have a computer somewhere else doing the work. When you open a normal website — YouTube, say — your computer asks a big machine far away to do things for you. This one doesn't. All the cutting and taping happens on your laptop, in the browser tab. That's why it's fast, why it works on an airplane with no internet, and why nobody in the world ever sees your file.
There's exactly one exception, the "Edit Text" tool, which needs a small helper program. We'll get to that at the end — and even then, the helper is a program you run on your own computer.
| Tool | What it does | Real-life version | |
|---|---|---|---|
| 🧩 | Merge PDF | Sticks 2+ PDFs into one, in whatever order you drag them | Stapling three worksheets together |
| ✂️ | Split PDF | Turns a 10-page PDF into 10 one-page PDFs, zipped up | Pulling every page apart and putting them in an envelope |
| 🗑️ | Remove Pages | Deletes the pages you tap | Ripping out the boring pages |
| 📑 | Extract Pages | Keeps only the pages you tap | Photocopying just pages 4 and 7 |
| 🔀 | Reorder Pages | Drag pages into a new order | Shuffling the stack |
| 🔄 | Rotate PDF | Turns pages 90° at a time | Someone scanned it sideways. Again. |
| 📝 | Edit Text | Change words that are already on the page | Erasing a word and writing a new one in the same handwriting |
| 🔢 | Add Page Numbers | Stamps 1, 2, 3… at the bottom | Numbering pages by hand |
| ✏️ | Add Text | Click anywhere, type something new | Scribbling a note in the margin |
Every one of these is fully offline except Edit Text (📝).
flowchart TD
A["🏠 Home screen<br/>9 tool cards"] -->|click a card| B["📥 Upload screen<br/>drop a PDF or click to browse"]
B -->|browser reads the file| C["🛠️ Workspace<br/>see your pages as little pictures"]
C -->|"click / drag / type,<br/>depending on the tool"| C
C -->|press <b>Process</b>| D["✅ Result screen"]
D -->|press <b>Download</b>| E["💾 New PDF saved<br/>to your Downloads folder"]
D -->|press <b>Start over</b>| A
C -->|press <b>← Tools</b>| A
style A fill:#eef2fb,stroke:#2f6fed
style D fill:#eef7ef,stroke:#63b06b
style E fill:#eef7ef,stroke:#63b06b
The whole website is one single page. There are four <section> blocks in index.html, and only one of them is visible at a time. "Changing screens" is really just JavaScript adding the CSS class active to one section and taking it off the others — the function showScreen() in app.js:37. Nothing ever reloads.
pdf/
├── index.html the skeleton — the 4 screens, and nothing else
├── style.css the paint job — colors, spacing, the tool-card grid
├── app.js the brain — every single thing the site does (~490 lines)
├── main.py the optional helper server, for the Edit Text tool
├── requirements.txt the 4 Python packages that helper needs
├── Dockerfile a recipe for running the helper in a container
└── docs/ the pictures used in this README
Only three files matter for the website itself: index.html, style.css, app.js.
There is no build step, no npm install, no framework. You can open index.html by double-clicking it and the whole thing works.
Writing PDF code from scratch would take years, so the site loads two free tools from the internet (a CDN), plus a zip helper:
| Library | Job | Think of it as… |
|---|---|---|
| pdf-lib | Changes PDFs — copies pages, rotates them, draws text | The scissors and tape |
| pdf.js | Shows PDFs — turns a page into a picture on screen | The eyes |
| JSZip | Bundles many files into one .zip |
The envelope |
They're loaded by the three <script> tags at the top of index.html. That's the only thing the site fetches from the internet.
Each of those tags carries an integrity="sha384-…" fingerprint of the exact file that was reviewed. The browser hashes what the CDN sends and refuses to run it if the two do not match. These three libraries touch every byte of your PDF, so "whatever the CDN happens to serve today" is not good enough. If you bump a version, recompute the hash:
curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -AEverything the app is currently thinking about lives in one object (app.js:33):
state = {
tool: null, // which tool you picked
files: [], // the actual file(s) you dropped in
fileBytes: [], // those files as raw bytes (for Merge)
pdfDoc: null, // the PDF, opened by pdf-lib
pageOrder: [], // e.g. [0,1,2,3] — and Reorder shuffles this list
removedSet: new Set(), // pages you tapped to delete
keepSet: new Set(), // pages you tapped to keep
rotationDelta: {}, // { pageIndex: 90 }
texts: {}, // new text you added
edits: {}, // existing text you changed
}Pressing ← Tools or Start over calls resetState(), which throws the whole notebook away and starts fresh. That's why the app never gets confused between two jobs.
The clever bit is pageOrder. The app almost never really moves pages around while you're working — it just rearranges this little list of numbers. Only at the very end does it build a real PDF by copying pages in that order (copyOrderTo(), app.js:428). Shuffling numbers is instant; shuffling actual PDF pages is slow.
sequenceDiagram
participant You
participant Browser as Browser (app.js)
participant PdfLib as pdf-lib
participant PdfJs as pdf.js
You->>Browser: drop MyHomework.pdf
Browser->>Browser: handleFiles() — read the file into raw bytes
Browser->>PdfLib: PDFDocument.load(bytes)
PdfLib-->>Browser: an openable PDF + how many pages
Browser->>Browser: pageOrder = [0,1,2,...]
Browser->>PdfJs: draw each page small
PdfJs-->>Browser: little canvas pictures
Browser->>You: show the thumbnail grid
Those little page pictures ("thumbnails") are made in renderThumbs() (app.js:181). For each page, pdf.js paints it onto a <canvas> at 40% size — a canvas is just a rectangle the browser can draw pixels on. Then, depending on which tool you chose, the code attaches different behavior to each thumbnail:
- Remove → clicking toggles it into
removedSetand shows a ✕ - Extract → clicking toggles it in
keepSetand shows a ✓ (everything starts selected) - Rotate → each thumbnail gets a little ⟳ button
- Reorder → each thumbnail becomes draggable
- Add Text / Edit Text → clicking opens the big page editor
Same grid, different personality. That's the whole design of the app.
One switch statement (app.js:388) sends you to the right function:
flowchart LR
P["Press <b>Process</b>"] --> S{which tool?}
S -->|merge| M["doMerge()<br/>copy every page of every file<br/>into one new PDF"]
S -->|split| SP["doSplit()<br/>make a 1-page PDF per page,<br/>zip them all up"]
S -->|remove| R["doKeepExcluding()<br/>copy pages NOT in removedSet"]
S -->|extract| E["doKeepOnly()<br/>copy pages IN keepSet"]
S -->|reorder| RE["doReorder()<br/>copy pages in pageOrder"]
S -->|rotate| RO["doRotate()<br/>set each page's angle, then copy"]
S -->|numbers| N["doPageNumbers()<br/>drawText '1','2','3'… at the bottom"]
S -->|text| T["doAddText()<br/>drawText where you clicked"]
S -->|edit| ED["doEditText()<br/>⚠ ask the helper server"]
M & SP & R & E & RE & RO & N & T & ED --> O["showResult()<br/>wrap bytes in a Blob,<br/>make a download link"]
style ED fill:#fff6ea,stroke:#e0a24a
style O fill:#eef7ef,stroke:#63b06b
Notice how boring most of them are — Remove, Extract and Reorder are the same function with a different list of page numbers. Once you see that, the app is much smaller than it looks.
At the end, showResult() (app.js:492) turns the finished bytes into a Blob (a pretend file that only exists in memory) and makes a URL for it. Clicking Download creates an invisible <a download> link and clicks it for you. The "file" was never on any server — it was invented in your RAM about a second earlier.
PDF coordinates are upside down. In a browser, y=0 is the top. In a PDF, y=0 is the bottom. So when you click to add text, the code has to flip it (app.js:469):
y: pageHeight - t.y / t.scaleIt also divides by scale, because the page was drawn 1.4× bigger than real life so you could see it. Screen pixels → real PDF points.
Add Text lies to you for a moment. When you click and type, the text you see is painted on the canvas — just pixels, not part of the PDF yet. The real text is only written into the PDF when you press Process, by pdf-lib's drawText. That's why the preview font and the final font don't match exactly.
Everything above is pure browser magic. Edit Text isn't, and here's the honest reason why.
To change a word that's already in a PDF and have it look right, you need to do two hard things:
- Really delete the old word — not paint a white box over it. A white box still leaves the letters underneath; anyone can copy-paste them out. That's how documents get "redacted" badly and end up in the news.
- Write the new word in the exact same font — and that font is often stored inside the PDF file itself as a little embedded font program.
A web browser flatly cannot do #2. There's no API that lets JavaScript pull an embedded font program out of a PDF and re-encode new letters against it. So the browser hands this one job to a small Python program.
sequenceDiagram
participant You
participant Browser as Browser
participant Server as main.py (PyMuPDF)
You->>Browser: click "Edit Text", pick a page
Browser->>Server: POST /api/inspect (the PDF + page number)
Server-->>Browser: every text run: where it is, its font,<br/>size, colour, bold/italic
Browser->>You: invisible editable boxes right on top of the words
You->>Browser: type a new word, click away
Browser->>Browser: remember it in state.edits
You->>Browser: press Process
Browser->>Server: POST /api/apply-edits (the PDF + the list of changes)
Note over Server: 1. redact — actually remove the old glyphs<br/>2. pull the original embedded font out by xref<br/>3. draw the new text with that same font
Server-->>Browser: the finished PDF
Browser->>You: Download
Note: In the browser UI, if BACKEND_URL is left empty in app.js, the "Edit Text" tool card is cleanly disabled and dimmed with instructions to start main.py.
Redaction, in kid terms: a white sticker over a word is a lie — the word is still under it. PyMuPDF's add_redact_annot + apply_redactions is more like dissolving the ink. The letters are genuinely gone from the file.
Font matching: the server looks up the original font by its xref (a PDF's internal address number), extracts it, and re-embeds it so your new text is indistinguishable. If the font isn't embedded — some PDFs just say "use Helvetica, you've probably got it" — it falls back to the closest standard font, picking bold/italic/serif/mono from the style flags. That's pick_fallback_font() in main.py.
If you never set BACKEND_URL, this tool politely tells you it's unavailable and the other eight keep working exactly as before.
Double-click index.html. Done. You need internet the first time so the three CDN libraries can load, but nothing else.
To put it online, push it to GitHub and turn on GitHub Pages — it's just static files, so there's nothing to configure.
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000Or with Docker:
docker build -t pdf-backend .
docker run -p 8000:8000 pdf-backendThen open app.js and point BACKEND_URL (line 14) at wherever it's running:
const BACKEND_URL = "http://localhost:8000";Two things to be careful about before you show this to the world:
main.pycurrently allows requests from any website (allow_origins=["*"]). Change that to your real address, e.g.["https://yourname.github.io"].- This has to be a normal always-on server — your own machine, a VPS, Fly.io, Render. It cannot be a GitHub Actions runner: a runner only wakes up for CI events, it isn't listening for web requests, and the token you'd need to trigger one would be visible to anyone who viewed the page source.
For the eight offline tools: your file is opened, changed, and saved entirely inside your browser tab. No upload, no server, no logs, no account, no cookies. Close the tab and every trace is gone.
For Edit Text: the PDF is sent to the helper server — which is a program you started, on a machine you control. It holds the file in memory, does the edit, sends it back, and keeps nothing.
Two details that keep that promise honest:
- Filenames are text, never markup. The merge list writes each name with
textContent, so a file called<img src=x onerror=…>.pdfshows up as that literal string instead of running as code. It used to be built withinnerHTML, which meant a crafted filename could execute script in the page — and a script in the page can read the very bytes this section promises never leave your machine. - The helper server validates before it trusts. A page number outside the document, malformed
editsJSON, a missingbboxor a non-PDF upload all come back as a400explaining what was wrong, rather than a500that looks like the server broke.
- Undo. Right now the only undo is "Start over".
- A progress bar for big PDFs. A 300-page file renders 300 thumbnails one at a time and the tab just sits there.
- Compress PDF and PDF → images are the two obvious missing tools.
- Font and size pickers for Add Text, which is currently hard-coded to 14pt black.
- Bundle the libraries locally instead of using a CDN, so it truly works with zero internet on first load.