42 KiB
Self-Hosted PDF Editor — Implementation Plan
Project codename: paperjet (rename freely)
Document version: 1.1
Status: Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
v1.1 changes: resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level trash/restore added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as draw + type-to-signature with live multi-font preview.
1. Purpose & Scope
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on no external programs, no external containers, and no third-party APIs. Libraries and packages bundled into the application's own containers are permitted.
Non-goals (v1)
- Multi-user collaboration / real-time co-editing
- True content editing (reflowing or rewriting the PDF's existing text stream)
- OCR (can be added later as an annotation-source plugin)
- Cloud sync
The architecture must not foreclose these. Future features are designed for, not built now.
2. Key Architectural Decisions (and changes from earlier discussion)
These decisions supersede anything discussed earlier in planning. Where they change a prior assumption, the change and its rationale are called out.
2.1 Rendering happens on the client, not the server (CHANGED)
Earlier planning floated server-side page rendering (PyMuPDF renders each page to an image, the browser displays the image). We are not doing that. The browser renders pages directly with PDF.js.
Why: Client-side rendering gives crisp, GPU-accelerated, infinitely zoomable pages with no per-page network round-trip and far less bandwidth — which matters for the WAN/reverse-proxy use case. Server-side image rendering would be blurry on zoom, slow over WAN, and heavier on the server.
Consequence: The server's PDF engine (PyMuPDF) is used for exactly two things — generating small thumbnails for the home page, and flattening annotations into the final exported PDF. It never renders full pages for the live editor.
2.2 The server stores annotations opaquely; only export understands them
The backend persists the annotation array as a JSON blob and does not parse individual annotation types for normal CRUD. Only the export renderer has per-type logic, dispatched through a type registry. This is the core extensibility decision: a new annotation type (e.g. "stamp", "checkbox", "redaction") requires a new frontend tool and a new export-renderer function, with no database migration and no API change.
2.3 pdf-lib is dropped entirely (CHANGED)
Because all PDF writing happens server-side in PyMuPDF, the browser never manipulates raw PDF bytes. Earlier planning included pdf-lib as a fallback writer; it is removed. One rendering engine for display (PDF.js), one writing engine for export (PyMuPDF). Fewer engines, fewer coordinate-mismatch bugs.
2.4 Versions store annotation snapshots, not flattened PDFs — and exist to recover from accidental loss (CHANGED / IMPROVED)
Earlier planning suggested flattening a real PDF per version. Instead, a version is a lightweight JSON snapshot of the annotation state. The original uploaded PDF is immutable on disk; every version is that original plus a layer of annotations. Flattening to an actual PDF happens only on export/download.
Purpose drives the policy. Versioning here is a safety net against accidental loss, not a granular editing timeline. There are two distinct things a user can lose by accident, and each gets its own net:
- A whole document, deleted by mistake → recovered via a document-level trash/restore (soft delete, §6.2 / §9.6).
- Annotation work inside a document, cleared or overwritten by mistake → recovered via version checkpoints (§9.2).
Because the goal is recovery rather than fine-grained history, auto-checkpoints are taken at meaningful safety boundaries (document close, and immediately before any destructive operation like "clear all" or a restore) rather than on a noisy time-based cadence. Manual checkpoints are always available.
Why snapshots: they're kilobytes, not megabytes, so both nets are effectively free. Any version restores by loading its JSON, and exports on demand.
2.5 Canonical coordinate space is fixed and documented
All stored coordinates are in PDF points (1/72 inch), top-left origin, relative to the page's unrotated CropBox. This single rule is the most important correctness constraint in the project (see §8).
2.6 Session auth with hashed password, not HTTP Basic (CHANGED)
Because the app is WAN-exposed, auth is a real login screen backed by an Argon2id password hash and a signed, httpOnly, SameSite cookie — not HTTP Basic Auth. Login is rate-limited.
3. Technology Stack (final)
| Layer | Choice | Notes |
|---|---|---|
| Frontend language | TypeScript (strict mode) | Type safety across the PDF/screen coordinate boundary |
| Frontend framework | React 18 | Largest ecosystem; best-represented in all agents' training |
| Build tool | Vite | Fast, simple, standard |
| PDF rendering (client) | PDF.js (pdfjs-dist) |
Display only |
| Canvas editing overlay | Fabric.js | Text boxes, drawing, drag/resize/select handles |
| Frontend state | Zustand | Lightweight, predictable; good fit for editor + undo stack |
| Styling | Tailwind CSS v4 | CSS-first @theme config; fast iteration toward a clean Sejda-like light UI |
| Backend language | Python 3.12 | Chosen for PyMuPDF |
| Backend framework | FastAPI | Async, typed, auto-generates OpenAPI |
| PDF processing (server) | PyMuPDF (pymupdf / fitz) |
Thumbnails + export flatten. MuPDF compiled into the wheel — self-contained |
| Validation | Pydantic v2 | Mirrors the TS annotation types |
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (argon2-cffi) |
|
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
| ASGI server (backend) | uvicorn | Behind nginx |
| Orchestration | Docker Compose | |
| External programs / containers / APIs | None | Hard requirement |
The user's own nginx reverse proxy sits in front of this stack and is out of scope for the compose file (it already exists). The compose stack exposes one HTTP port for the proxy to target.
4. System Architecture
┌───────────────────────────┐
Internet (WAN) ───────▶│ User's nginx reverse proxy│ (existing, out of scope)
LAN ──────────────────▶│ TLS termination, routing │
└─────────────┬─────────────┘
│ http :8080
┌─────────────▼─────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ frontend │ │ backend │ │
│ │ nginx │─────▶│ FastAPI │ │
│ │ serves SPA │ /api │ uvicorn │ │
│ │ proxies /api│ │ PyMuPDF │ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
└────────────────────────────────────────────┘
- The
frontendnginx serves the SPA and reverse-proxies/api/*to thebackend. This means the browser sees a single origin — no CORS complexity, cookies "just work." - The user's outer reverse proxy points at the
frontendcontainer's published port.
5. Repository Structure
A single monorepo. Two deployable images.
paperjet/
├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml
├── .env.example
├── .github/workflows/ci.yml # lint + typecheck + test on push
│
├── frontend/
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
│ ├── # Tailwind v4: no config file — design tokens are defined via @theme in src/index.css
│ └── src/
│ ├── main.tsx
│ ├── app/ # routing, layout, theme
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── HomePage.tsx # recent + upload + library tabs
│ │ └── EditorPage.tsx
│ ├── features/
│ │ ├── auth/
│ │ ├── library/ # list, upload, delete, search
│ │ ├── editor/
│ │ │ ├── canvas/ # Fabric.js integration
│ │ │ ├── tools/ # one module per annotation tool
│ │ │ ├── toolbar/
│ │ │ ├── pages/ # PDF.js page rendering
│ │ │ └── history/ # undo/redo stack
│ │ └── versions/
│ ├── lib/
│ │ ├── coords.ts # ⚠ THE coordinate transform module (§8)
│ │ ├── api/ # generated/typed API client
│ │ └── annotations/ # shared annotation type defs + registry
│ └── types/ # generated from backend OpenAPI
│
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations
│ └── app/
│ ├── main.py # FastAPI app, router mounting
│ ├── config.py # env-driven settings (Pydantic Settings)
│ ├── db.py # engine, session, WAL pragma
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic request/response models
│ ├── api/v1/
│ │ ├── auth.py
│ │ ├── documents.py
│ │ ├── annotations.py
│ │ ├── versions.py
│ │ └── export.py
│ ├── services/
│ │ ├── storage.py # file IO on the volume
│ │ ├── thumbnails.py # PyMuPDF thumbnail generation
│ │ └── export/ # PyMuPDF flatten + type registry (§9.3)
│ │ ├── renderer.py
│ │ └── handlers/ # one handler per annotation type
│ ├── auth/ # hashing, session cookie, dependencies
│ └── tests/
│
└── shared/
└── annotation-schema.json # canonical schema; source for TS + Pydantic
shared/annotation-schema.json is the contract for the annotation data model. Both the TypeScript types and the Pydantic models derive from it. When the model changes, it changes here first.
6. Data Model
SQLite, WAL mode enabled. UUIDv4 string primary keys throughout.
6.1 Tables
settings (singleton row, id = 1)
| column | type | notes |
|---|---|---|
| id | INTEGER PK | always 1 |
| password_hash | TEXT | Argon2id; NULL until first-run setup |
| created_at | TEXT (ISO8601) | |
| updated_at | TEXT |
documents
| column | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| title | TEXT | display name, user-editable; defaults to filename |
| original_filename | TEXT | |
| file_path | TEXT | path on pdf_storage volume |
| thumbnail_path | TEXT | path on thumbnails volume; NULL until generated |
| size_bytes | INTEGER | |
| page_count | INTEGER | |
| created_at | TEXT | upload time |
| updated_at | TEXT | last edit (drives "recently edited") |
| deleted_at | TEXT | soft delete; NULL = active |
annotation_states (current working layer, one row per document)
| column | type | notes |
|---|---|---|
| document_id | TEXT PK FK | → documents.id |
| data | TEXT (JSON) | the annotation array (§7) |
| updated_at | TEXT | written by autosave |
versions (history snapshots)
| column | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| document_id | TEXT FK | |
| label | TEXT | NULL for auto-checkpoints |
| data | TEXT (JSON) | full annotation snapshot |
| kind | TEXT | manual | auto |
| created_at | TEXT |
Indexes: documents(updated_at), documents(deleted_at), versions(document_id, created_at).
6.2 Soft deletes / trash (v1 feature)
Deleting a document is a soft delete: it sets documents.deleted_at and moves the row out of the active library into a Trash view, from which it can be restored. This is the primary recovery path for an accidentally deleted file. The underlying PDF, thumbnail, and annotation state are retained while the document is in trash.
- Restore clears
deleted_at, returning the document to the library with its annotations intact. - Permanent delete happens either when the user empties trash explicitly, or automatically after a retention window (default 30 days in trash), at which point files and rows are purged. Retention is a config value.
- Bulk delete and bulk restore operate on multiple selected items.
7. Annotation Data Model
The single most reused structure in the system. Defined once in shared/annotation-schema.json, mirrored as a TS discriminated union and Pydantic models.
7.1 Envelope (common to all types)
{
"id": "uuid",
"page": 0, // 0-indexed
"type": "text", // discriminator; drives the registry
"rect": { // CANONICAL space: PDF points, top-left origin (§8)
"x": 72.0,
"y": 144.0,
"width": 200.0,
"height": 48.0
},
"rotation": 0, // degrees, clockwise, about rect center
"z": 3, // z-order within the page
"props": { /* type-specific, opaque to the server */ },
"createdAt": "2026-06-10T...",
"updatedAt": "2026-06-10T..."
}
7.2 Per-type props (v1 set)
| type | props shape (summary) |
|---|---|
text |
{ text, fontFamily, fontSize, color, align, bold, italic, lineHeight } |
draw |
{ paths: [[x,y]...], strokeColor, strokeWidth, opacity } (points in rect-local space) |
signature |
discriminated by mode: • draw → { mode: "draw", ref, strokeColor } (a captured raster/vector signature, stored as an asset) • type → { mode: "type", text, fontFamily, color } (rendered as text in a bundled signature font) |
image |
{ ref, naturalWidth, naturalHeight } (uploaded raster) |
highlight |
{ color, opacity } |
shape |
{ kind: "rect"|"ellipse"|"line"|"arrow", strokeColor, fillColor, strokeWidth } |
Large binary payloads (signature PNGs, placed images) are not inlined into the JSON. They are uploaded to an assets endpoint and referenced by id (ref). This keeps the annotation JSON small and the autosave PUT cheap. See §9.4.
7.3 Extensibility contract
- Adding a type = (a) add its
propsschema toannotation-schema.json, (b) add a frontend tool module undereditor/tools/, (c) register a frontend renderer + an export handler in the registries. No DB or API change. - The server must round-trip unknown
typevalues without error (forward compatibility): if an export handler is missing for a type, it is skipped with a logged warning, never a crash.
8. The Coordinate System (read this twice)
Canonical storage space: PDF points (1/72"), top-left origin (y increases downward), relative to each page's CropBox at 0° rotation.
Rationale for top-left origin: Fabric.js canvas space is top-left, and PyMuPDF's high-level API is top-left native — so two of the three engines need no flip. Only PDF.js (whose viewport PDF-point conversions are bottom-left) is flipped, in exactly one place.
lib/coords.ts is the only module permitted to convert between coordinate spaces. No component computes its own transforms inline. It exposes:
// canonical (PDF points, top-left) ↔ screen (CSS pixels at current zoom)
pdfToScreen(p: PdfPoint, viewport: PageViewport): ScreenPoint
screenToPdf(p: ScreenPoint, viewport: PageViewport): PdfPoint
pdfRectToScreen(r: PdfRect, viewport): ScreenRect
screenRectToPdf(r: ScreenRect, viewport): PdfRect
It must correctly handle, with unit tests for each:
- Zoom — scaling without cumulative drift.
- Page rotation — pages with intrinsic /Rotate of 90/180/270°.
- Device pixel ratio — retina vs standard displays.
- CropBox ≠ MediaBox — pages whose visible box is offset from the media origin.
- Y-axis flip for the PDF.js boundary specifically.
Verification gate: before any annotation tool is built, write a round-trip test — screenToPdf(pdfToScreen(p)) ≈ p within 0.01pt across all rotations and zoom levels — and a cross-engine test that places a known rect via the frontend transform and confirms PyMuPDF stamps it in the same visual location. The export renderer and the live editor must agree pixel-for-pixel. This gate is non-negotiable; misalignment here corrupts every saved annotation.
9. Backend Behavior
9.1 Autosave model
- Frontend debounces 500ms after the last edit, then
PUT /documents/{id}/annotationswith the full current annotation array (idempotent full-state replace — simpler and more crash-safe than diff patches at this scale). - The PUT updates
annotation_states.dataand bumpsdocuments.updated_at. - Concurrent-tab safety: last write wins (single user, acceptable). Include an
updatedAtin the payload so a stale tab can be detected and warned, but do not block.
9.2 Versioning model
- Manual checkpoint:
POST /documents/{id}/versionssnapshots current working state intoversionswithkind=manualand an optional label. Manual versions are kept indefinitely. - Auto checkpoint: the backend snapshots automatically at safety boundaries — on document close, and immediately before any destructive operation (a "clear all", or a version restore). This keeps the net focused on recoverable accidents rather than logging every keystroke. Auto versions are retained for a rolling window (default 30 days, config-driven) and pruned beyond it; manual versions are never auto-pruned.
- Restore:
POST /documents/{id}/versions/{vid}/restorefirst auto-snapshots the current state (so a restore is itself undoable), then copies the target version'sdatainto the working state.
In-document accidental edits during a single session are also covered by the in-memory undo/redo stack (§12.3); versions are the cross-session and pre-destructive-action net.
9.3 Export (flatten) via type registry
services/export/renderer.py opens the original PDF with PyMuPDF, iterates the annotation array, and dispatches each by type to a handler in services/export/handlers/. Each handler receives (page, annotation, coord_ctx) and draws into the page using PyMuPDF primitives (insert_textbox, draw_line, insert_image, draw_rect, etc.). Output is written to a temp file and streamed back.
- The registry is a dict
{type: handler}; adding a type adds a handler, nothing else. - Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional
versionIdto export a historical version instead of the working state. - Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for
textannotations; and (b) a small curated set of signature/script fonts for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
9.4 Assets (binary annotation payloads)
POST /documents/{id}/assets(multipart) stores a signature PNG or placed image on thepdf_storagevolume, returns{ ref }.- Annotations reference assets by
ref; the JSON stays small. - Orphan assets (unreferenced after edits) are swept periodically.
9.5 Thumbnails
- On upload, generate a single first-page thumbnail (PyMuPDF, ~300px wide) to
thumbnails/. Cheap. Regenerate lazily if missing. Used by home-page cards.
9.6 Trash / restore
- Delete sets
documents.deleted_at; the document leaves the active library and appears in Trash. - Restore clears
deleted_at. - A periodic sweep permanently purges documents whose
deleted_atis older than the retention window (default 30 days), deleting their PDF, thumbnail, assets, annotation state, and versions. Purge is also triggerable manually via "empty trash". - All list queries for the active library filter
deleted_at IS NULL; the trash view filtersdeleted_at IS NOT NULL.
10. Full API Specification
Base path /api/v1. All responses JSON unless noted. All endpoints except auth/* and health require a valid session cookie; unauthenticated requests get 401. Errors use a consistent envelope: { "error": { "code": str, "message": str } }.
10.1 Auth
| Method | Path | Body | Returns | Notes |
|---|---|---|---|---|
| GET | /auth/status |
— | { authenticated: bool, setupRequired: bool } |
setupRequired true if no password set |
| POST | /auth/setup |
{ password } |
204 |
First-run only; 409 if already set |
| POST | /auth/login |
{ password } |
204 + sets cookie |
Rate-limited (see §11) |
| POST | /auth/logout |
— | 204 |
Clears cookie |
| PUT | /auth/password |
{ currentPassword, newPassword } |
204 |
403 on mismatch |
10.2 Documents
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| GET | /documents |
?sort=recent|title&q=&deleted=false&limit=&offset= |
{ items: DocumentMeta[], total } |
| POST | /documents |
multipart file |
DocumentMeta (201) |
| GET | /documents/{id} |
— | DocumentMeta |
| PATCH | /documents/{id} |
{ title? } |
DocumentMeta |
| DELETE | /documents/{id} |
— | 204 (soft delete → trash) |
| POST | /documents/bulk-delete |
{ ids: string[] } |
{ deleted: number } (soft) |
| POST | /documents/{id}/restore |
— | DocumentMeta (un-trash) |
| POST | /documents/bulk-restore |
{ ids: string[] } |
{ restored: number } |
| DELETE | /documents/{id}?permanent=true |
— | 204 (hard delete from trash) |
| POST | /documents/trash/empty |
— | { purged: number } |
| GET | /documents/{id}/file |
— | application/pdf (original bytes; PDF.js fetches this) |
| GET | /documents/{id}/thumbnail |
— | image/png |
?deleted=false (default) lists the active library; ?deleted=true lists trash. Hard delete requires the explicit permanent=true flag so it can never happen by accident from the normal delete path.
DocumentMeta:
{ "id", "title", "originalFilename", "sizeBytes", "pageCount",
"createdAt", "updatedAt", "thumbnailUrl" }
10.3 Annotations (working state)
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /documents/{id}/annotations |
— | { data: Annotation[], updatedAt } |
| PUT | /documents/{id}/annotations |
{ data: Annotation[], baseUpdatedAt? } |
{ updatedAt } |
baseUpdatedAt (optional) lets the server warn on stale-tab overwrite (returns 409 with current state if provided and mismatched; client may force with a retry omitting it).
10.4 Assets
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /documents/{id}/assets |
multipart file |
{ ref, url } |
| GET | /documents/{id}/assets/{ref} |
— | binary (image/png etc.) |
10.5 Versions
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /documents/{id}/versions |
— | { items: VersionMeta[] } |
| POST | /documents/{id}/versions |
{ label? } |
VersionMeta (201) |
| GET | /documents/{id}/versions/{vid} |
— | { data: Annotation[], meta } |
| POST | /documents/{id}/versions/{vid}/restore |
— | { updatedAt } |
| DELETE | /documents/{id}/versions/{vid} |
— | 204 |
VersionMeta: { id, label, kind, createdAt, annotationCount }.
10.6 Export
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /documents/{id}/export |
{ versionId?, flatten: true } |
application/pdf (synchronous; single-user scale) |
If export ever becomes slow for huge files, migrate to a job pattern (202 + GET /jobs/{id}). The endpoint shape is designed so this is additive, not breaking.
10.7 Health
| Method | Path | Returns |
|---|---|---|
| GET | /health |
{ status: "ok", version } |
10.8 OpenAPI is generated, not hand-maintained
FastAPI emits /api/v1/openapi.json. The frontend's typed API client and types/ are generated from it (e.g. openapi-typescript). Agents must regenerate types after any schema change rather than hand-editing them. This keeps frontend and backend in lockstep.
11. Authentication & Security
- Single password, no username. Stored as Argon2id hash in
settings.password_hash. - First-run setup: if
setupRequired, the UI shows a "set your password" screen before anything else./auth/setupworks only while no hash exists. - Session: on login, issue a signed token (itsdangerous or JWT) in a cookie that is
httpOnly,Secure,SameSite=Lax, with a sane expiry (e.g., 7 days, sliding). No token in localStorage (XSS-safe). - Login rate limiting: exponential backoff / lockout after N failed attempts per IP (e.g., 5 attempts → 15-min lockout). In-memory counter is fine for single-user; document it.
- CSRF: because auth is cookie-based, protect state-changing routes. With
SameSite=Laxplus a customX-Requested-Withheader required on mutating requests (the SPA always sends it; cross-site form posts can't), CSRF is adequately mitigated for single-user. Document the reasoning. - Upload validation: verify magic bytes (
%PDF-), enforce a max size (config), and open with PyMuPDF in a guarded try/except to reject malformed/malicious files before storing. Never trust the client-supplied content-type or filename; sanitize filenames. - Path safety: all file paths derive from server-generated UUIDs, never from user input. No user string ever becomes a filesystem path.
- Secrets:
SECRET_KEY, cookie settings, max upload size, etc. come from environment variables (.env), never hardcoded. - Security headers (set in nginx):
Content-Security-Policy(restrict to self; allow the wasm/worker for PDF.js),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer. - TLS is terminated at the user's outer reverse proxy. In production, all traffic — LAN included — goes through that HTTPS proxy, so
COOKIE_SECURE=trueand the browser always sees HTTPS; theSecurecookie flag is honored. Direct HTTP access to the published port is only for local development/testing, whereCOOKIE_SECURE=falseis set so the session cookie works over plain HTTP. This single env var is the only difference between the two modes; document both in.env.example.
12. Frontend Architecture
12.1 Pages
- LoginPage — password entry (and first-run setup variant).
- HomePage — three zones in a clean light layout:
- Recently edited — cards (thumbnail, title, edited-time), sorted by
updatedAt. - Upload — prominent button and full-page drag-and-drop dropzone with progress.
- Library tab — grid/list of all PDFs with multi-select, search, and delete (single + bulk). Selection mode reveals a delete action bar.
- Trash view — soft-deleted documents with restore (single + bulk) and permanent delete / empty trash. Shows days remaining before auto-purge.
- Recently edited — cards (thumbnail, title, edited-time), sorted by
- EditorPage — the canvas workspace.
12.2 Editor composition
- PageStack — renders each PDF page via PDF.js into a
<canvas>, lazily (virtualized) for large docs. - AnnotationLayer — a Fabric.js canvas overlaid per page, sized to the rendered page, fed transforms from
lib/coords.ts. - Toolbar — tool selection (select, text, draw, signature, image, highlight, shape), color/size controls, zoom, undo/redo, save-status indicator, export.
- Tool modules (
editor/tools/*) — each tool implements a small interface:onActivate,onPointerDown/Move/Up,createAnnotation,renderToFabric. Registered in a frontend tool registry mirroring the backend handler registry. - VersionsPanel — list, label, restore, delete; opens as a side drawer.
12.3 Editor state (Zustand)
document,annotations(working set),selection,activeTool,zoom,saveStatus.- Undo/redo: a command-stack history of annotation-set mutations (add/modify/delete/transform). Each user action pushes an inverse command. History is per-document and in-memory (lost on reload — acceptable; persisted versions cover durable history). Keep the stack bounded (e.g., 100 steps).
- Autosave subscribes to annotation-set changes, debounced.
12.5 Signature capture
Triggered by the signature tool, a modal offers two tabs (Sejda-style):
- Draw — a freehand canvas (smoothed strokes) where the user signs with mouse/trackpad/touch/stylus. On confirm, the drawn signature is rasterized to a transparent PNG, uploaded via the assets endpoint, and placed as a
signatureannotation withmode: "draw"referencing the asset. - Type — a text input plus a live preview that renders the typed text simultaneously in several bundled signature/script fonts (the same faces available server-side for export). The user picks a font; on confirm a
signatureannotation withmode: "type"is created carrying the text, chosenfontFamily, and color.
Once placed, a signature behaves like any other annotation — drag, resize, rotate, delete, undo/redo. Recently used signatures may be offered for quick re-insertion (future enhancement; not required for v1). Because typed signatures are stored as text + font (not flattened to an image until export), they stay crisp at any zoom and re-render identically on the server.
12.6 UI / visual design direction
Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrained palette (one accent color), soft shadows, rounded corners, clear iconography. Design tokens (colors, spacing, radius, shadows, typography) defined once via Tailwind v4's CSS-first @theme directives in src/index.css. The editor chrome stays out of the way; the page is the focus. Mobile/touch: the canvas supports pinch-zoom and touch drag; toolbar collapses into an overflow menu on narrow viewports. Define a small design-token set (spacing scale, radius, accent, neutrals) up front so all three agents produce visually consistent components.
13. Docker Compose & Deployment
# docker-compose.yml (illustrative; agents finalize)
services:
backend:
build: ./backend
environment:
- SECRET_KEY=${SECRET_KEY}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
restart: unless-stopped
volumes:
pdf_storage:
thumbnails:
db:
frontend/nginx.confserves the SPA with history-API fallback and proxies/api/tobackend:8000, passing through cookies and theX-Requested-Withheader. Setclient_max_body_sizeto matchMAX_UPLOAD_MB(≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their outer reverse proxy, since that's the other place a large upload can be silently truncated.- A 200 MB ceiling comfortably covers 100–200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
14. Extensibility / Future Features (design for, don't build)
| Future feature | How the design already accommodates it |
|---|---|
| New annotation types (stamp, redaction, checkbox) | Annotation registry + opaque server storage; no schema/API change |
| OCR / searchable text | Add as an "annotation source" that emits text annotations; export already handles text |
| Form filling (AcroForms) | Add a formfield annotation type + PyMuPDF widget handler; API unchanged |
| Async export for huge files | Export endpoint shape allows 202 + job migration additively |
| Multi-user | Add a users table + per-row ownership; auth already cookie/session based |
| Page operations (merge/split/rotate/reorder) | New document-level endpoints; PyMuPDF supports natively, no new programs |
| Real-time collaboration | Autosave is full-state today; swap to CRDT/WebSocket later without changing storage canon |
Rule: never hardcode the v1 tool list anywhere that would require editing in multiple places to extend. Tools and export handlers are discovered from their registries.
15. Rules for AI Agents
Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the whole file) into every working session.
- Read
ARCHITECTURE.md(this file) fully before writing code. Do not infer architecture from existing partial code. - Conform to the API contract. Never invent endpoint shapes, field names, or status codes. If the contract is insufficient, propose a change to this document first; do not improvise divergent shapes.
- One coordinate-transform module. All space conversions go through
lib/coords.ts. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8). - Store coordinates only in canonical space. Never persist screen pixels.
- Types are generated, not hand-written. After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with
shared/annotation-schema.json. - No external programs, containers, or third-party APIs. Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
- Extend via registries. New annotation types/tools register themselves; never scatter
switch(type)logic across the codebase. Unknown types must round-trip without crashing. - Security code is review-gated. Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
- Tests are required for the risk areas: the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
- Small, single-purpose changes. One feature or fix per change set. Conventional-commit messages. Don't refactor unrelated code in a feature change.
- Frontend is TypeScript strict; backend is fully type-hinted. No
anyon the frontend without a written justification. CI runs lint + typecheck + tests and must pass. - Respect the rendering split: PDF.js renders for display; PyMuPDF writes for export. Don't introduce a second display renderer or a second writer.
- Idempotent, full-state autosave. Don't switch to diff-based saving without updating this document and the version model.
- When uncertain, ask the plan, not the vibe. If two reasonable implementations exist, pick the one this document specifies; if it doesn't specify, propose an amendment here before coding.
16. Implementation Phases
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
Phase 0 — Scaffolding
Monorepo, both Dockerfiles, compose, .env.example, CI (lint/typecheck/test), nginx config, empty FastAPI app with /health, SPA shell with routing. Done when: docker compose up serves a blank authenticated-shell app and /api/v1/health responds.
Phase 1 — Auth & Library core First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + trash/restore (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). Done when: a user can log in, upload, see, delete, and restore PDFs.
Phase 2 — Coordinate module & rendering (CRITICAL)
lib/coords.ts with full unit tests (round-trip across zoom/rotation/DPR). PDF.js page rendering in the editor. The cross-engine verification gate (§8) against PyMuPDF. Done when: the verification gate passes. No annotation tools before this.
Phase 3 — Annotation engine & text tool
Annotation data model end-to-end, Fabric.js overlay, the text tool (add/move/resize/edit), working-state GET/PUT, debounced autosave, save-status UI. Done when: a text box can be placed, moved, autosaved, reloaded, and round-trips through canonical space correctly.
Phase 4 — Remaining tools draw, signature (draw + type-to-signature with live multi-font preview), image (+ assets endpoint), highlight, shape — each via the tool/handler registries. Done when: all v1 tools place and persist.
Phase 5 — Versioning & undo/redo Manual + auto checkpoints (on close and before destructive ops), versions panel, restore, retention prune; in-memory undo/redo command stack. Done when: history can be checkpointed, browsed, and restored; undo/redo works within a session.
Phase 6 — Export PyMuPDF flatten via the handler registry, bundled fonts, export of working state or a chosen version. Done when: exported PDFs match the on-screen editor pixel-for-pixel across all tools, rotations, and zoom levels.
Phase 7 — Polish Sejda-grade light UI pass, mobile/touch refinement, error states, empty states, large-document virtualization, accessibility basics, backup documentation.
17. Resolved Decisions (formerly open questions)
All four open questions are now settled:
- Upload ceiling: 200 MB (
MAX_UPLOAD_MB=200), comfortably covering 100–200 page documents with embedded images. No separate page-count ceiling; PDF.js virtualization handles large docs. The same limit must be raised in both the app's nginx and the user's outer reverse proxy. - Versioning purpose & policy: the safety net against accidental loss. A whole-document accidental delete is recovered via trash/restore (30-day retention). Accidental annotation loss inside a document is recovered via version checkpoints — taken manually anytime, and automatically on document close and before destructive operations (clear-all, restore). Manual versions kept indefinitely; auto versions retained 30 days.
- Networking: production routes all traffic (LAN included) through the HTTPS reverse proxy with
COOKIE_SECURE=true. Direct HTTP to the published port is dev/testing only, withCOOKIE_SECURE=false. - Signature capture: two modes — draw on canvas, and type-to-signature with multiple bundled signature fonts and a Sejda-style live preview rendering the typed text in each font as you type. Both stored as
signatureannotations (mode: "draw"|mode: "type"); the same fonts are bundled server-side so export matches the preview exactly.
End of plan v1.1. Amendments are made to this document first, then to code.