edit-pdf
by rifqilubis
README.md
# edit·pdf
Edit PDFs with AI agents. Two ways to drive it:
1. **Built-in agent** — chat in the app, bring your own API key.
2. **External agent over MCP** — connect Claude Code / Codex / Cursor, and it edits the **same live document** you have open in the browser.
Edits appear in the open tab within ~1.5 s, from either side. Every change is an undoable batch.
---
## Why this exists
Most "AI PDF" tools paste a new PDF over the old one, so the result is a picture: the text you "replaced" is still there underneath, still copyable, still searchable. That is dangerous for anything you redact.
This project treats editing as a **replayable list of operations** applied by a real PDF engine, and redaction genuinely deletes glyphs from the content stream.
---
## Quick start
```bash
bun install
bun dev # http://localhost:3000
```
Drop a PDF on the landing page. That creates a session with a short id (`/s/abc12345`).
To use the built-in agent, open **settings** in the agent panel and add an API key. It is kept in your browser's `localStorage` and sent to your local server per request — never written to disk.
Alternatively, set a server-side key:
```bash
OPENAI_API_KEY=sk-... bun dev
# or
ANTHROPIC_API_KEY=sk-ant-... bun dev
```
Any OpenAI-compatible endpoint works (OpenRouter, Ollama, LM Studio, vLLM) via the **Base URL** field.
---
## Connecting an external agent (MCP)
The app exposes an MCP server that talks to the running dev server, so an external agent operates on the session you already have open.
```bash
bun run mcp
```
Register it with your client. For Claude Code:
```bash
claude mcp add edit-pdf -- bun run /absolute/path/to/edit-pdf/mcp/server.ts
```
Or in a JSON config:
```json
{
"mcpServers": {
"edit-pdf": {
"command": "bun",
"args": ["run", "/absolute/path/to/edit-pdf/mcp/server.ts"],
"env": { "EDIT_PDF_URL": "http://localhost:3000" }
}
}
}
```
Then, in a conversation with the agent:
> List my edit-pdf sessions, open the one called `invoice.pdf`, and change the total to $0.00.
The agent calls `list_sessions` → `use_session` → `read_document` → `find_text` → `edit_pdf`, and the change shows up in your open browser tab.
### MCP tools
| Tool | Purpose |
| --- | --- |
| `list_sessions` | Sessions currently open in the app |
| `use_session` | Pin a default session for the conversation |
| `read_document` | Page count, sizes, per-page text preview |
| `find_text` | Locate text, returns exact boxes |
| `edit_pdf` | Apply operations |
| `render_page` | Render a page to PNG to verify visually |
| `undo_last_edit` | Revert the last batch |
| `download_pdf` | URL to download the current PDF |
---
## Coordinate systems (the part that bites)
Two spaces exist, and mixing them is the most common source of misplaced edits:
| Space | Origin | Y direction | Used by |
| --- | --- | --- | --- |
| **PDF user space** | bottom-left | up | `find_text`, `page_text`, the raw PDF |
| **Edit space** | **top-left** | **down** | `edit_pdf` operations, UI drag selection |
`find_text` returns both: `x`/`y` (PDF space) and a ready-to-use `topLeft` box. Pass `topLeft` straight into `edit_pdf` and you cannot get it wrong.
Units are PDF points (1/72 inch).
---
## Operations
All coordinates are in **edit space** (top-left origin, y down).
| Op | Notes |
| --- | --- |
| `add_text` | `maxWidth` auto-shrinks to fit; `rotate` in degrees |
| `replace_text` | Uses `find` (or an explicit `box`). **Removes the original glyphs**, then writes the new text |
| `redact` | Removes text in the box from the content stream, then paints over it |
| `highlight` | Semi-transparent rectangle |
| `draw_rect` / `draw_line` | Vector primitives |
| `add_image` | Base64 PNG/JPEG data URL |
| `watermark` | Diagonal text, all pages or a subset |
| `delete_page` / `insert_blank_page` / `move_page` / `rotate_page` | Structural |
| `page_numbers` | `{n}` / `{total}` placeholders |
| `set_meta` | Title, author, subject, keywords |
Example:
```json
{
"operations": [
{ "type": "replace_text", "page": 1, "find": "Total: $1,299.00", "text": "Total: $0.00" },
{ "type": "watermark", "text": "DRAFT", "opacity": 0.2 },
{ "type": "page_numbers", "position": "bottom-center" }
]
}
```
---
## HTTP API
Everything the UI does goes through this API, so you can script it directly.
```bash
# Upload
curl -X POST localhost:3000/api/sessions -F file=@invoice.pdf
# -> {"session":{"id":"abc12345", ...}}
# Inspect
curl localhost:3000/api/sessions/abc12345/read
curl "localhost:3000/api/sessions/abc12345/find?q=Total"
# Edit
curl -X POST localhost:3000/api/sessions/abc12345/ops \
-H 'Content-Type: application/json' \
-d '{"operations":[{"type":"page_numbers"}]}'
# Render a page
curl -o page1.png "localhost:3000/api/sessions/abc12345/page?page=1&scale=2"
# Undo / download
curl -X DELETE localhost:3000/api/sessions/abc12345/ops
curl -o out.pdf "localhost:3000/api/sessions/abc12345?download=1"
```
Pass `baseVersion` with an edit to get a `409` if the document changed underneath you, instead of silently clobbering a concurrent edit.
---
## How it works
```
app/ Next.js App Router UI + API routes
components/ Workspace, PageViewer, AgentPanel, SettingsDialog
lib/pdf/engine.ts Op -> pdf-lib mutations (the core)
lib/pdf/content-stream.ts PDF content-stream tokenizer + text stripping
lib/pdf/redact.ts Walks page streams and Form XObjects, rewrites them
lib/pdf/render.ts pdf.js rendering + positioned text extraction
lib/agent/tools.ts Tool schemas + executeTool (shared by app and MCP)
lib/server/store.ts Sessions, history, render cache
mcp/server.ts MCP bridge to the running server
```
**Deterministic edits.** A session stores its original upload plus the ordered list of op batches. Undo replays the remaining batches from the original — so undo is exact, not "apply the inverse and hope".
**True redaction.** `content-stream.ts` tokenizes the page's content stream, tracks the graphics-state matrix (`q`/`Q`/`cm`) and text matrices (`Tm`/`Td`/`T*`), and drops the `Tj`/`TJ`/`'`/`"` operators whose origin lands inside the box. Text inside `Do`-referenced Form XObjects is scrubbed too. The result is re-serialised uncompressed.
**One tool surface.** The in-app agent and the MCP server both route through `executeTool`, so they cannot drift apart in capability or behaviour.
---
## Limits
- **Standard fonts only.** Non-Latin text (Arabic, CJK, emoji) is coerced to `?` because WinAnsi encoding cannot represent it. Embedding a Unicode font is the fix; it is not implemented.
- **RTL scripts** are not shaped or reordered.
- **Redaction misses rasterised text.** If a page is a scanned image, there are no glyphs to strip — only the visible box is applied, and the tool says so.
- **Covered text in complex layouts.** Stripping is by text-run origin. A run that starts outside the box but extends into it is left intact.
- **Sessions are local.** Stored in `.data/sessions/`, no auth. This is a local-first tool; do not expose it to the internet as-is.
- **40 MB upload cap.**
---
## Development
```bash
bun dev # dev server
bun run build # production build
bun run typecheck
bun run mcp # MCP server (needs a running dev server)
```
`node` is not required — everything runs on Bun.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues