# 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 `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work." - The user's outer reverse proxy points at the `frontend` container'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) ```jsonc { "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 `props` schema to `annotation-schema.json`, (b) add a frontend tool module under `editor/tools/`, (c) register a frontend renderer + an export handler in the registries. **No DB or API change.** - The server must round-trip unknown `type` values 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: ```ts // 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: 1. **Zoom** — scaling without cumulative drift. 2. **Page rotation** — pages with intrinsic /Rotate of 90/180/270°. 3. **Device pixel ratio** — retina vs standard displays. 4. **CropBox ≠ MediaBox** — pages whose visible box is offset from the media origin. 5. **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}/annotations` with 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.data` and bumps `documents.updated_at`. - Concurrent-tab safety: last write wins (single user, acceptable). Include an `updatedAt` in the payload so a stale tab can be detected and warned, but do not block. ### 9.2 Versioning model - **Manual checkpoint:** `POST /documents/{id}/versions` snapshots current working state into `versions` with `kind=manual` and 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}/restore` first auto-snapshots the *current* state (so a restore is itself undoable), then copies the target version's `data` into 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 `versionId` to 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 `text` annotations; 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 the `pdf_storage` volume, 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_at` is 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 filters `deleted_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`: ```jsonc { "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/setup` works 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=Lax` plus a custom `X-Requested-With` header 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=true` and the browser always sees HTTPS; the `Secure` cookie flag is honored. **Direct HTTP access to the published port is only for local development/testing**, where `COOKIE_SECURE=false` is 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. - **EditorPage** — the canvas workspace. ### 12.2 Editor composition - **PageStack** — renders each PDF page via PDF.js into a ``, 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 `signature` annotation with `mode: "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 `signature` annotation with `mode: "type"` is created carrying the text, chosen `fontFamily`, 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 ```yaml # 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.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_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. 1. **Read `ARCHITECTURE.md` (this file) fully before writing code.** Do not infer architecture from existing partial code. 2. **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. 3. **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). 4. **Store coordinates only in canonical space.** Never persist screen pixels. 5. **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`. 6. **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. 7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing. 8. **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. 9. **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. 10. **Small, single-purpose changes.** One feature or fix per change set. Conventional-commit messages. Don't refactor unrelated code in a feature change. 11. **Frontend is TypeScript strict; backend is fully type-hinted.** No `any` on the frontend without a written justification. CI runs lint + typecheck + tests and must pass. 12. **Respect the rendering split:** PDF.js renders for display; PyMuPDF writes for export. Don't introduce a second display renderer or a second writer. 13. **Idempotent, full-state autosave.** Don't switch to diff-based saving without updating this document and the version model. 14. **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**, with `COOKIE_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 `signature` annotations (`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.*