Skip to main content
Glama
rifqilubis

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