# PDF Stack Overhaul — Design **Date:** 2026-05-12 **Branch:** `feat/documents-folders` **Status:** Design approved; pending user review of spec; implementation planned via writing-plans skill. ## Goal Replace `pdfme` (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with `@react-pdf/renderer` (JSX components, real layout primitives). Add `unpdf` for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule). ## Scope (locked) ### KEEP & migrate to `@react-pdf/renderer` (internal-only) | Surface | Current location | Caller | | ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- | | Activity report | `src/lib/pdf/templates/reports/activity-report.ts` | `src/lib/services/reports.service.ts` | | Revenue report | `src/lib/pdf/templates/reports/revenue-report.ts` | same | | Pipeline report | `src/lib/pdf/templates/reports/pipeline-report.ts` | same | | Occupancy report | `src/lib/pdf/templates/reports/occupancy-report.ts` | same | | Client summary export | `src/lib/pdf/templates/client-summary-template.ts` | `src/lib/services/record-export.ts` | | Berth spec export | `src/lib/pdf/templates/berth-spec-template.ts` | same | | Interest summary export | `src/lib/pdf/templates/interest-summary-template.ts` | same | | Expense sheet | `src/lib/services/expense-pdf.service.ts` (currently uses pdfme indirectly via `expense-export.ts`) | same | ### REMOVE entirely | Removal | Reason | | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `src/lib/pdf/templates/invoice-template.ts` + `generatePdf` call in `invoices.ts:604` + API route `/api/v1/invoices/[id]/generate-pdf` | Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature. | | `src/lib/pdf/tiptap-to-pdfme.ts` (571 lines) + API route `/api/v1/admin/templates/preview` + `generatePdf` block in `document-templates.ts:516` | TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore. | | `src/lib/pdf/templates/eoi-standard-inapp.ts` (337 lines, HTML seed) + seed-data references | Only used as the seed `bodyHtml` text on a `document_templates` row. The in-app EOI is rendered by `fill-eoi-form.ts` (pdf-lib), not from this HTML. Safe to drop. | | `src/lib/pdf/generate.ts` (24 lines) | Pdfme wrapper; replaced by `src/lib/pdf/render.ts`. | | Deps: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` | Replaced by `@react-pdf/renderer`. | ### STAYS UNTOUCHED - `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm fill on `assets/eoi-template.pdf`) — the in-app EOI pathway. - `src/lib/services/berth-pdf-parser.ts` tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets `unpdf` for PDF→image rasterization. - `pdf-lib` dep (still needed by `fill-eoi-form.ts` and `berth-pdf-parser.ts`). - All Documenso integration code. ## Architecture Three orthogonal PDF paths post-migration, each with a single owner: ``` ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ │ react-pdf (this phase) │ │ pdf-lib AcroForm fill │ │ Documenso (external) │ │ Internal only │ │ Standardized + signing │ │ Client-facing signed │ │ │ │ │ │ docs │ │ • Reports (×4) │ │ • In-app EOI │ │ │ │ • Expenses │ │ • Future admin-upload │ │ (handled outside our │ │ • Record exports (×3) │ │ invoice templates │ │ system) │ │ • Future internal lists │ │ (deferred) │ │ │ └────────────┬─────────────┘ └────────────┬─────────────┘ └────────────────────────┘ │ │ ▼ ▼ src/lib/pdf/render.ts src/lib/pdf/fill-eoi-form.ts (renderToBuffer + (unchanged this phase) renderToStream) │ ▼ src/lib/pdf/brand-kit/ ├─ DocumentShell.tsx ├─ Header.tsx ├─ Footer.tsx ├─ DataTable.tsx ├─ KeyValueGrid.tsx ├─ Section.tsx ├─ Badge.tsx ├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx ├─ tokens.ts └─ logo.ts ``` ### Module boundaries - **`brand-kit/`** — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements. - **`templates/`** — one `.tsx` per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service. - **`render.ts`** — the only module that touches `@react-pdf/renderer`'s `renderToBuffer` / `renderToStream`. Services call `renderPdf()` or `renderPdfStream()`. - **`logo.ts`** — `resolvePortLogo(portId)` reads `system_settings.port_logo_file_id` and returns `{ source, buffer, mimeType }`. Cached per request via React `cache()`. - **Chart rendering** — pure SVG components emitting react-pdf's native `` primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component. - **Photo embedding** (expense PDFs) — `sharp` (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries. ### Header layout constraint The brand-kit `
` reserves a fixed logo slot: ``` maxWidth: 200 (≈ 56mm) maxHeight: 60 (≈ 17mm) objectFit: contain // letterbox, never stretch align: left, vertically centered within the dark header band fallback: when resolvePortLogo returns 'fallback', render {port.name} at the same slot. The port-name + doc-title combination keeps the header visually balanced. ``` This is enforced inside `
`, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly. ### Brand kit tokens ```ts // src/lib/pdf/brand-kit/tokens.ts export const PDF_TOKENS = { colors: { text: '#111111', textMuted: '#666666', border: '#e5e7eb', headerBand: '#0f172a', // dark slate — matches CRM sidebar headerText: '#ffffff', accentBlue: '#1d4ed8', zebra: '#f9fafb', success: '#16a34a', warning: '#d97706', danger: '#dc2626', }, fonts: { sans: 'Helvetica', sansBold: 'Helvetica-Bold', mono: 'Courier', }, sizes: { docTitle: 18, sectionH: 13, body: 10, small: 8, caption: 7, }, spacing: { pagePadding: 36, sectionGap: 18, rowGap: 6, }, } as const; ``` Single source of truth. Future design pass = edit this file, every PDF updates. ## Logo handling ### Layer 1 — Server-side sharp normalization (required) ``` upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF) → reject animated GIF / multi-frame PNG / multi-page TIFF → size cap 5MB raw → if SVG: sanitize first via svgo (strip