From 81a98c66952f4031dd0f774c0b8ad15979c6e18f Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 20:36:54 +0200 Subject: [PATCH] docs(superpowers): pdf stack overhaul design (react-pdf + unpdf) Lays out the plan to replace pdfme with @react-pdf/renderer, add unpdf for berth-PDF tier-2 rasterization, and add port-level logo upload (sharp normalization + react-image-crop UI + svgo sanitization + rasterize-SVG-to-PNG-on-upload). Scope locked to internal-only PDFs (reports, expenses, record exports). Invoice + admin TipTap-to-PDF removed entirely; in-app EOI pathway (pdf-lib AcroForm fill) stays untouched. 14 commits planned. Single source of truth for tokens. Three orthogonal PDF paths post-migration with no overlap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-12-pdf-stack-overhaul-design.md | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md diff --git a/docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md b/docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md new file mode 100644 index 00000000..35053d2a --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md @@ -0,0 +1,491 @@ +# 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