docs(05): research phase PDF fill and field mapping
This commit is contained in:
577
.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md
Normal file
577
.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# Phase 5: PDF Fill and Field Mapping - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-19
|
||||||
|
**Domain:** PDF coordinate systems, drag-and-drop field placement, pdf-lib text fill, Next.js API routes
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| DOC-04 | Agent can drag-and-drop to place signature fields on any page of a PDF document | dnd-kit free-position drag + react-pdf Page overlay pattern; coordinate conversion formula |
|
||||||
|
| DOC-05 | Agent can fill in text fields (property address, client names, dates, prices) before sending | pdf-lib `PDFDocument.load` + `page.drawText` + `form.flatten()`; server route POST handler |
|
||||||
|
| DOC-06 | Agent can assign a document to a specific client and initiate a signing request | DB schema adds `signatureFields` JSONB + `assignedClientId` + `status` transition; existing documents table extended |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 5 has three tightly coupled problems: (1) the UI for placing signature fields on a rendered PDF, (2) the coordinate system translation between screen pixels and PDF user space, and (3) the server-side PDF mutation (fill text + embed signature placeholder rectangles) using a pure-JS PDF library.
|
||||||
|
|
||||||
|
The dominant stack for this problem domain is `react-pdf` (wojtekmaj) for rendering + `dnd-kit` for drag interaction + `pdf-lib` (or its maintained fork) for server-side PDF mutation. This combination is proven in the open source community and aligns with the libraries already in use in this codebase. The critical math is the Y-axis flip: PDF user space has bottom-left origin with Y increasing upward; DOM/screen space has top-left origin with Y increasing downward. Every stored coordinate must use PDF user space so that downstream signing and pdf-lib embedding work correctly.
|
||||||
|
|
||||||
|
The `pdf-lib` package (Hopding, v1.17.1) is unmaintained — last published 4 years ago. The actively maintained fork `@cantoo/pdf-lib` (v2.6.1, published 18 days ago) is a drop-in replacement with the same API and adds SVG support plus encrypted PDF support. Use `@cantoo/pdf-lib` instead of the original. The signature field approach is to store a plain colored rectangle overlay in the PDF using `pdf-lib`'s `page.drawRectangle` with a border (no AcroForm widget needed at this stage — Phase 6 will embed the actual signature image into that zone).
|
||||||
|
|
||||||
|
**Primary recommendation:** Use `dnd-kit` for drag-to-place, read `Page.onLoadSuccess` for scale, apply the Y-flip formula to store PDF-space coords in a JSONB column, and use `@cantoo/pdf-lib` on the server to draw text + rectangle overlays before transitioning document status to "Sent".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| `@dnd-kit/core` | ^6.x | Drag sensor, context, drag events | Lightweight, accessible, React 19 compatible; works with free-position canvas |
|
||||||
|
| `@dnd-kit/utilities` | ^3.x | `CSS.Translate.toString(transform)` helper | Reduces boilerplate in draggable style binding |
|
||||||
|
| `@cantoo/pdf-lib` | ^2.6.1 | Server-side PDF mutation: draw text, rectangles, flatten | Actively maintained fork of unmaintained `pdf-lib`; drop-in API replacement |
|
||||||
|
| `react-pdf` | ^10.4.1 | Already installed; renders PDF pages as canvases | Already in project; `Page.onLoadSuccess` exposes `originalWidth`/`originalHeight` needed for coordinate math |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `@pdf-lib/fontkit` | ^1.1.1 | Custom font embedding into pdf-lib | Needed only if non-Latin characters appear in text fields; Utah forms are English-only so Helvetica (built-in) suffices for Phase 5 |
|
||||||
|
| `drizzle-orm/pg-core` `jsonb()` | already installed | Store `signatureFields` array as JSONB | Native to existing Drizzle + Postgres stack |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| `@cantoo/pdf-lib` | `pdf-lib` 1.17.1 | Original is unmaintained (4 years); `@cantoo/pdf-lib` is drop-in replacement with active fixes |
|
||||||
|
| `@cantoo/pdf-lib` | `pdfme` or PSPDFKit | pdfme is a full template system (overkill); PSPDFKit is commercial (violates "zero per month" goal) |
|
||||||
|
| `dnd-kit` | `react-draggable` | Both work; dnd-kit has better React 19 support, accessibility hooks, and modifier system for snapping |
|
||||||
|
| `dnd-kit` | `hello-pangea/dnd` | hello-pangea is better for list reordering, not free-canvas positioning |
|
||||||
|
| DB JSONB for field coords | Separate `document_fields` table | JSONB is simpler and queryable; fields for one document are always read together, no relational join needed |
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
```bash
|
||||||
|
npm install @dnd-kit/core @dnd-kit/utilities @cantoo/pdf-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ └── portal/
|
||||||
|
│ └── (protected)/
|
||||||
|
│ └── documents/
|
||||||
|
│ └── [docId]/
|
||||||
|
│ ├── page.tsx # Server component (existing, extend with edit UI)
|
||||||
|
│ └── _components/
|
||||||
|
│ ├── PdfViewer.tsx # Existing — extend with overlay layer
|
||||||
|
│ ├── PdfViewerWrapper.tsx # Existing dynamic wrapper
|
||||||
|
│ ├── FieldPlacer.tsx # NEW: drag-and-drop overlay + field palette
|
||||||
|
│ └── TextFillForm.tsx # NEW: text field form (address, names, dates)
|
||||||
|
├── app/
|
||||||
|
│ └── api/
|
||||||
|
│ └── documents/
|
||||||
|
│ └── [id]/
|
||||||
|
│ ├── fields/
|
||||||
|
│ │ └── route.ts # NEW: GET/PUT signature field coordinates
|
||||||
|
│ └── prepare/
|
||||||
|
│ └── route.ts # NEW: POST — fill text + burn sig rectangles, set status
|
||||||
|
├── lib/
|
||||||
|
│ └── db/
|
||||||
|
│ └── schema.ts # Extend documents table: signatureFields JSONB
|
||||||
|
└── lib/
|
||||||
|
└── pdf/
|
||||||
|
└── prepare-document.ts # NEW: pdf-lib server utility
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Coordinate Conversion (Screen → PDF User Space)
|
||||||
|
|
||||||
|
**What:** Convert browser click/drop coordinates to PDF user-space coordinates (bottom-left origin, points).
|
||||||
|
**When to use:** Every time a signature field is placed or moved on the PDF overlay.
|
||||||
|
|
||||||
|
The `Page` component's `onLoadSuccess` callback provides `originalWidth` and `originalHeight` (in PDF points). The rendered canvas size can be read from `getBoundingClientRect()` on the page container ref.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://www.pdfscripting.com/public/PDF-Page-Coordinates.cfm
|
||||||
|
// and https://github.com/wojtekmaj/react-pdf/blob/main/packages/react-pdf/src/Page.tsx
|
||||||
|
|
||||||
|
interface PdfPageInfo {
|
||||||
|
originalWidth: number; // PDF points (e.g., 612 for US Letter)
|
||||||
|
originalHeight: number; // PDF points (e.g., 792 for US Letter)
|
||||||
|
width: number; // Rendered px = originalWidth * scale
|
||||||
|
height: number; // Rendered px = originalHeight * scale
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert screen (DOM) coordinates to PDF user-space coordinates.
|
||||||
|
* PDF origin is bottom-left; DOM origin is top-left — Y must be flipped.
|
||||||
|
*
|
||||||
|
* @param screenX - X in pixels relative to the top-left of the rendered page
|
||||||
|
* @param screenY - Y in pixels relative to the top-left of the rendered page
|
||||||
|
* @param pageInfo - from Page onLoadSuccess callback
|
||||||
|
* @returns { x, y } in PDF points with bottom-left origin
|
||||||
|
*/
|
||||||
|
function screenToPdfCoords(
|
||||||
|
screenX: number,
|
||||||
|
screenY: number,
|
||||||
|
pageInfo: PdfPageInfo
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const { width: renderedW, height: renderedH, originalWidth, originalHeight } = pageInfo;
|
||||||
|
const pdfX = (screenX / renderedW) * originalWidth;
|
||||||
|
// Y-axis flip: PDF Y=0 is at the bottom; DOM Y=0 is at the top
|
||||||
|
const pdfY = ((renderedH - screenY) / renderedH) * originalHeight;
|
||||||
|
return { x: pdfX, y: pdfY };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Drag-and-Drop Field Placement with dnd-kit
|
||||||
|
|
||||||
|
**What:** Palette of draggable field tokens on the left; PDF page as the drop zone. On drop, compute PDF coordinates and store in state.
|
||||||
|
**When to use:** The primary agent interaction for placing signature fields.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://docs.dndkit.com/api-documentation/draggable/drag-overlay
|
||||||
|
'use client';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
// Stored shape of a placed field
|
||||||
|
interface SignatureField {
|
||||||
|
id: string;
|
||||||
|
page: number; // 1-indexed
|
||||||
|
x: number; // PDF user-space points (bottom-left origin)
|
||||||
|
y: number; // PDF user-space points (bottom-left origin)
|
||||||
|
width: number; // PDF points
|
||||||
|
height: number; // PDF points
|
||||||
|
}
|
||||||
|
|
||||||
|
// In parent component:
|
||||||
|
function onDragEnd(event: DragEndEvent, pageInfo: PdfPageInfo, pageContainerRef: React.RefObject<HTMLDivElement>) {
|
||||||
|
if (!event.over || !pageContainerRef.current) return;
|
||||||
|
const containerRect = pageContainerRef.current.getBoundingClientRect();
|
||||||
|
// event.delta gives displacement from start; for absolute drop position
|
||||||
|
// use the dragged item's final position relative to the drop zone container
|
||||||
|
const dropX = event.activatorEvent instanceof MouseEvent
|
||||||
|
? event.activatorEvent.clientX + event.delta.x - containerRect.left
|
||||||
|
: 0;
|
||||||
|
const dropY = event.activatorEvent instanceof MouseEvent
|
||||||
|
? event.activatorEvent.clientY + event.delta.y - containerRect.top
|
||||||
|
: 0;
|
||||||
|
const pdfCoords = screenToPdfCoords(dropX, dropY, pageInfo);
|
||||||
|
// Persist new field to state / server action
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Overlay Rendering (Stored Fields → Visual Position)
|
||||||
|
|
||||||
|
**What:** Render stored PDF-space fields back as absolutely-positioned div overlays on top of the `<Page>` canvas.
|
||||||
|
**When to use:** After loading the document and its stored fields.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Reverse mapping: PDF user-space → screen pixels for rendering
|
||||||
|
function pdfToScreenCoords(
|
||||||
|
pdfX: number,
|
||||||
|
pdfY: number,
|
||||||
|
pageInfo: PdfPageInfo
|
||||||
|
): { left: number; top: number } {
|
||||||
|
const { width: renderedW, height: renderedH, originalWidth, originalHeight } = pageInfo;
|
||||||
|
const left = (pdfX / originalWidth) * renderedW;
|
||||||
|
// Reverse Y flip
|
||||||
|
const top = renderedH - (pdfY / originalHeight) * renderedH;
|
||||||
|
return { left, top };
|
||||||
|
}
|
||||||
|
|
||||||
|
// In JSX (inside the <Page> wrapper div, position: relative):
|
||||||
|
{fields.map(field => {
|
||||||
|
const { left, top } = pdfToScreenCoords(field.x, field.y, pageInfo);
|
||||||
|
const widthPx = (field.width / pageInfo.originalWidth) * pageInfo.width;
|
||||||
|
const heightPx = (field.height / pageInfo.originalHeight) * pageInfo.height;
|
||||||
|
return (
|
||||||
|
<div key={field.id} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left,
|
||||||
|
top: top - heightPx, // top is y of bottom-left corner; shift up by height
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
border: '2px solid #2563eb',
|
||||||
|
background: 'rgba(37,99,235,0.1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Server-Side PDF Preparation with pdf-lib
|
||||||
|
|
||||||
|
**What:** POST `/api/documents/[id]/prepare` — load the stored PDF, fill AcroForm text fields OR draw text directly, draw signature rectangles, flatten, overwrite the file.
|
||||||
|
**When to use:** When agent clicks "Prepare & Send" after filling text fields and placing signature fields.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://pdf-lib.js.org/ and https://github.com/Hopding/pdf-lib
|
||||||
|
// Uses @cantoo/pdf-lib (drop-in replacement API)
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
export async function preparePdf(filePath: string, fillData: Record<string, string>, signatureFields: SignatureField[]) {
|
||||||
|
const pdfBytes = await readFile(filePath);
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
|
||||||
|
// Strategy A: fill existing AcroForm text fields by name
|
||||||
|
try {
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
for (const [fieldName, value] of Object.entries(fillData)) {
|
||||||
|
try {
|
||||||
|
const field = form.getTextField(fieldName);
|
||||||
|
field.setText(value);
|
||||||
|
} catch {
|
||||||
|
// Field name not found — skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.flatten();
|
||||||
|
} catch {
|
||||||
|
// No AcroForm — use Strategy B: draw text at known coordinates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy B (fallback or supplement): draw text directly on page
|
||||||
|
// Used for custom fields or when no AcroForm exists
|
||||||
|
// Coordinates come from Phase 5 UI or hardcoded known positions
|
||||||
|
// (Implementation specific to this app's text field schema)
|
||||||
|
|
||||||
|
// Draw signature field placeholders (visible rectangle for client to sign)
|
||||||
|
for (const field of signatureFields) {
|
||||||
|
const page = pages[field.page - 1]; // 0-indexed
|
||||||
|
if (!page) continue;
|
||||||
|
page.drawRectangle({
|
||||||
|
x: field.x,
|
||||||
|
y: field.y,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
borderColor: rgb(0.15, 0.39, 0.92), // blue
|
||||||
|
borderWidth: 1.5,
|
||||||
|
color: rgb(0.85, 0.91, 0.99), // light blue fill
|
||||||
|
});
|
||||||
|
page.drawText('Sign Here', {
|
||||||
|
x: field.x + 4,
|
||||||
|
y: field.y + 4,
|
||||||
|
size: 8,
|
||||||
|
font: helvetica,
|
||||||
|
color: rgb(0.15, 0.39, 0.92),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedBytes = await pdfDoc.save();
|
||||||
|
await writeFile(filePath, modifiedBytes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Schema Extension for Field Storage
|
||||||
|
|
||||||
|
**What:** Add `signatureFields` JSONB column to the `documents` table.
|
||||||
|
**When to use:** Migration for Phase 5.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/db/schema.ts addition
|
||||||
|
import { jsonb } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// TypeScript type for stored field
|
||||||
|
export interface SignatureFieldData {
|
||||||
|
id: string;
|
||||||
|
page: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to documents table:
|
||||||
|
export const documents = pgTable('documents', {
|
||||||
|
// ... existing columns ...
|
||||||
|
signatureFields: jsonb('signature_fields').$type<SignatureFieldData[]>(),
|
||||||
|
assignedClientId: text('assigned_client_id'), // client to send to (may duplicate existing clientId — confirm at planning)
|
||||||
|
textFillData: jsonb('text_fill_data').$type<Record<string, string>>(), // { propertyAddress, buyerName, etc. }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Storing screen pixels in the database:** Always convert to PDF user-space before persisting. Screen pixels are resolution/zoom dependent; PDF points are absolute.
|
||||||
|
- **Assuming `originalHeight = page.view[3]`:** Some PDFs have non-standard mediaBox ordering. Use `Math.max(page.view[1], page.view[3])` for `originalHeight` to handle both orderings.
|
||||||
|
- **Using `form.flatten()` before adding rectangles:** Flatten removes form fields and ends AcroForm editing. Draw signature rectangles AFTER flattening text fields.
|
||||||
|
- **Using `pdf-lib` 1.17.1 (Hopding):** Unmaintained for 4 years. Use `@cantoo/pdf-lib` instead — same API, actively maintained.
|
||||||
|
- **Mutating the original file in place without backup:** The prepare step overwrites the stored PDF. Keep a copy strategy or store the "prepared" variant under a new file path to allow re-editing (discuss at planning).
|
||||||
|
- **Mixing `width` and `scale` props on `<Page>`:** If both are set, width is multiplied by scale — double scaling. Use only `scale` prop and let the component compute width.
|
||||||
|
- **Defining `devicePixelRatio` without capping it:** On retina displays, `window.devicePixelRatio` can be 3x, making canvas 9x as many pixels. Cap at `Math.min(2, window.devicePixelRatio)` for performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Drag sensor, keyboard fallback, accessibility | Custom mouse event listeners | `dnd-kit` `useDraggable`/`useDroppable` | WCAG 2.5.7 requires keyboard alternative; dnd-kit provides it |
|
||||||
|
| PDF byte manipulation, text drawing | Custom PDF writer | `@cantoo/pdf-lib` | PDF spec is 1000+ pages; correct font encoding, content streams, and AcroForm structure require expert implementation |
|
||||||
|
| Y-axis coordinate flip | Anything other than the formula | The formula: `pdfY = ((renderedH - screenY) / renderedH) * originalHeight` | There is one correct formula; any other approach introduces drift |
|
||||||
|
| JSONB type safety | Custom serialization | `drizzle-orm/pg-core` `jsonb().$type<T>()` | Already in the stack; provides TypeScript type safety on read |
|
||||||
|
| PDF rendering to canvas | Custom PDF.js integration | `react-pdf` (already installed) | Already in project; Page component provides `onLoadSuccess` with dimensions |
|
||||||
|
|
||||||
|
**Key insight:** The coordinate math is simple (four-line formula) but the failure mode (fields visually misplaced) is catastrophic and silent. Do the math correctly once, unit-test it against real Utah form dimensions (612×792 pts for Letter), and never re-derive it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Y-Axis Inversion (Silent Correctness Bug)
|
||||||
|
**What goes wrong:** Signature fields appear in the wrong vertical position. At scale=1 the error is `originalHeight - 2*pdfY` points off — visually wrong but not obviously so until you look carefully.
|
||||||
|
**Why it happens:** DOM Y increases downward; PDF Y increases upward. Developers forget the flip and store raw screen Y.
|
||||||
|
**How to avoid:** Unit test the conversion: for a point clicked at the visual top of a US Letter page (screenY ≈ 0), the resulting pdfY should be ≈ 792 (near the top in PDF space). If pdfY ≈ 0, you forgot the flip.
|
||||||
|
**Warning signs:** Placed fields appear near the bottom of the document when you clicked near the top.
|
||||||
|
|
||||||
|
### Pitfall 2: Scale Factor Drift
|
||||||
|
**What goes wrong:** Fields placed at zoom 1.0 appear slightly offset when the PDF is zoomed to 1.5.
|
||||||
|
**Why it happens:** If `originalWidth`/`originalHeight` are read at one scale and screen coordinates at another, the ratio is wrong.
|
||||||
|
**How to avoid:** Always compute `pdfX = (screenX / renderedWidth) * originalWidth` using the CURRENT rendered dimensions from the container's `getBoundingClientRect()` at the moment of the drop event — not stale state from a previous render.
|
||||||
|
**Warning signs:** Fields appear correct immediately after placement but shift when zooming in/out.
|
||||||
|
|
||||||
|
### Pitfall 3: pdf-lib AcroForm Field Name Mismatch
|
||||||
|
**What goes wrong:** `form.getTextField('PropertyAddress')` throws — the actual field name in the Utah form PDF differs.
|
||||||
|
**Why it happens:** Utah real estate PDFs from utahrealestate.com often have internal field names like `Text1`, `Text2` rather than semantic names. The field names are not visible in the UI.
|
||||||
|
**How to avoid:** Enumerate all field names first: `pdfDoc.getForm().getFields().map(f => f.getName())`. Build the fill-data schema around actual names from the specific PDFs. Wrap `getTextField` in a try/catch and log misses.
|
||||||
|
**Warning signs:** No error, but text fields remain empty in the output PDF.
|
||||||
|
|
||||||
|
### Pitfall 4: Mutating the Only Copy of the PDF
|
||||||
|
**What goes wrong:** If prepare fails mid-way (corrupt PDF output), the original document is lost.
|
||||||
|
**Why it happens:** `writeFile(filePath, ...)` overwrites in place.
|
||||||
|
**How to avoid:** Write to a temp path first (`${filePath}.tmp`), verify the output is a valid PDF (check magic bytes `%PDF`), then `rename(tmpPath, filePath)`.
|
||||||
|
**Warning signs:** Document becomes unopenable after a failed prepare attempt.
|
||||||
|
|
||||||
|
### Pitfall 5: `originalHeight = 0` on Some PDFs
|
||||||
|
**What goes wrong:** `page.view[3]` is 0 for some PDFs with non-standard mediaBox ordering.
|
||||||
|
**Why it happens:** PDF spec allows specifying `[x1, y1, x2, y2]` where the first pair can be non-zero (e.g., `[0, 792, 612, 0]`).
|
||||||
|
**How to avoid:** `const originalHeight = Math.max(page.view[1], page.view[3])` and similarly for width.
|
||||||
|
**Warning signs:** Coordinate calculations produce infinity or 0 division.
|
||||||
|
|
||||||
|
### Pitfall 6: `@cantoo/pdf-lib` Import Path
|
||||||
|
**What goes wrong:** Code that imports from `pdf-lib` may not automatically resolve to the fork.
|
||||||
|
**Why it happens:** `@cantoo/pdf-lib` is a different package name, not an alias.
|
||||||
|
**How to avoid:** Install `@cantoo/pdf-lib` and update all imports. Do NOT install both `pdf-lib` and `@cantoo/pdf-lib` — they will conflict.
|
||||||
|
**Warning signs:** TypeScript reports duplicate default exports or type conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Reading Page Dimensions for Coordinate Math
|
||||||
|
```typescript
|
||||||
|
// Source: https://github.com/wojtekmaj/react-pdf/blob/main/packages/react-pdf/src/Page.tsx
|
||||||
|
// and https://github.com/wojtekmaj/react-pdf/discussions/1535
|
||||||
|
import { Page } from 'react-pdf';
|
||||||
|
|
||||||
|
const [pageInfo, setPageInfo] = useState<{
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scale: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
<Page
|
||||||
|
pageNumber={currentPage}
|
||||||
|
scale={scale}
|
||||||
|
onLoadSuccess={(page) => {
|
||||||
|
setPageInfo({
|
||||||
|
// Use Math.max to handle non-standard mediaBox
|
||||||
|
originalWidth: Math.max(page.view[0], page.view[2]),
|
||||||
|
originalHeight: Math.max(page.view[1], page.view[3]),
|
||||||
|
width: page.width, // rendered px
|
||||||
|
height: page.height, // rendered px
|
||||||
|
scale: page.scale,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Schema with JSONB Fields
|
||||||
|
```typescript
|
||||||
|
// Source: https://orm.drizzle.team/docs/custom-types
|
||||||
|
// and https://wanago.io/2024/07/15/api-nestjs-json-drizzle-postgresql/
|
||||||
|
import { jsonb } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export interface SignatureFieldData {
|
||||||
|
id: string;
|
||||||
|
page: number; // 1-indexed
|
||||||
|
x: number; // PDF user space, bottom-left origin, points
|
||||||
|
y: number; // PDF user space, bottom-left origin, points
|
||||||
|
width: number; // PDF points
|
||||||
|
height: number; // PDF points
|
||||||
|
}
|
||||||
|
|
||||||
|
// In pgTable:
|
||||||
|
signatureFields: jsonb('signature_fields').$type<SignatureFieldData[]>(),
|
||||||
|
textFillData: jsonb('text_fill_data').$type<Record<string, string>>(),
|
||||||
|
```
|
||||||
|
|
||||||
|
### dnd-kit Draggable Field Token
|
||||||
|
```typescript
|
||||||
|
// Source: https://docs.dndkit.com/api-documentation/draggable/drag-overlay
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
function DraggableSignatureToken() {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
|
id: 'signature-field-token',
|
||||||
|
data: { type: 'signature', width: 144, height: 36 }, // 2in x 0.5in in points
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
cursor: 'grab',
|
||||||
|
}}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
Signature Field
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### pdf-lib: Fill Text and Draw Signature Rectangle
|
||||||
|
```typescript
|
||||||
|
// Source: https://pdf-lib.js.org/ (using @cantoo/pdf-lib — drop-in replacement)
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
|
||||||
|
|
||||||
|
async function fillDocumentFields(
|
||||||
|
pdfBytes: Uint8Array,
|
||||||
|
textFields: Record<string, string>,
|
||||||
|
sigFields: Array<{ page: number; x: number; y: number; width: number; height: number }>
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
// Fill AcroForm fields if they exist
|
||||||
|
try {
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
for (const [name, value] of Object.entries(textFields)) {
|
||||||
|
try { form.getTextField(name).setText(value); } catch { /* field not found */ }
|
||||||
|
}
|
||||||
|
form.flatten(); // Must happen BEFORE drawing rectangles
|
||||||
|
} catch { /* No AcroForm — ignore */ }
|
||||||
|
|
||||||
|
// Draw signature placeholders
|
||||||
|
for (const field of sigFields) {
|
||||||
|
const page = pages[field.page - 1];
|
||||||
|
if (!page) continue;
|
||||||
|
page.drawRectangle({
|
||||||
|
x: field.x,
|
||||||
|
y: field.y,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
borderColor: rgb(0.15, 0.39, 0.92),
|
||||||
|
borderWidth: 1.5,
|
||||||
|
color: rgb(0.90, 0.94, 0.99),
|
||||||
|
});
|
||||||
|
page.drawText('Sign Here', {
|
||||||
|
x: field.x + 4,
|
||||||
|
y: field.y + 4,
|
||||||
|
size: 8,
|
||||||
|
font: helvetica,
|
||||||
|
color: rgb(0.15, 0.39, 0.92),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await pdfDoc.save();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| `pdf-lib` 1.17.1 (Hopding) | `@cantoo/pdf-lib` 2.6.1 | Original abandoned ~2022 | Drop-in replacement; use fork to get bug fixes |
|
||||||
|
| Manual CDN worker URL for pdf.js | `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)` | pdf.js v4+ / react-pdf v9+ | Already in project; no CDN required |
|
||||||
|
| `react-pdf` CommonJS | react-pdf v10 ESM-only | June 2024 (v10.0.0) | Already in project; `transpilePackages` in next.config.ts handles this |
|
||||||
|
| `useFormState` (React DOM) | `useActionState` (React 19) | React 19 / Next.js 15+ | Already established in project (STATE.md decision) |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `pdf-lib` direct import: Unmaintained; replace with `@cantoo/pdf-lib`
|
||||||
|
- CDN pdf.js worker URL: Already replaced in project with `import.meta.url` pattern
|
||||||
|
- Storing screen-pixel coordinates in database: Must always convert to PDF user space before persist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Prepared PDF file path strategy**
|
||||||
|
- What we know: Currently documents are stored at `uploads/clients/{clientId}/{docId}.pdf`
|
||||||
|
- What's unclear: Should Phase 5 overwrite the original (lossy — can't re-edit) or write to a separate `_prepared.pdf` path (preserves original for re-editing)?
|
||||||
|
- Recommendation: Write to a new path `uploads/clients/{clientId}/{docId}_prepared.pdf` and store it in a new `preparedFilePath` column — keeps the original editable. Decide at planning.
|
||||||
|
|
||||||
|
2. **Text field schema — agent input form**
|
||||||
|
- What we know: DOC-05 requires filling in "property address, client names, dates, prices"
|
||||||
|
- What's unclear: Are these always the same fields across all Utah forms, or variable per form template?
|
||||||
|
- Recommendation: Build a generic key/value text fill form (agent types field labels and values). Store as `textFillData JSONB`. Phase 5 does not need to pre-define field names — agent provides them.
|
||||||
|
|
||||||
|
3. **AcroForm field names in Utah forms**
|
||||||
|
- What we know: Utah real estate PDFs from utahrealestate.com may or may not have named AcroForm fields
|
||||||
|
- What's unclear: Phase 4 loaded them but never inspected AcroForm structure
|
||||||
|
- Recommendation: Add an enumeration step: `GET /api/documents/[id]/fields` that reads the PDF and returns discovered AcroForm field names. Agent uses this to map their fill data. Fall back to `drawText` at hardcoded positions if no AcroForm.
|
||||||
|
|
||||||
|
4. **DOC-06 "initiate a signing request" scope boundary**
|
||||||
|
- What we know: DOC-06 says "assign to a client and initiate signing request"
|
||||||
|
- What's unclear: Does "initiate" mean setting status to "Sent" + calling the email API (Phase 6 work), or just status transition + persisting data?
|
||||||
|
- Recommendation: Phase 5 ends at: prepared PDF is saved, `signatureFields` and `textFillData` are persisted, document `status` transitions from `Draft` to `Sent` (or a new `Prepared` status). Actual email sending is Phase 6. Clarify the status enum at planning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- `react-pdf` GitHub (wojtekmaj/react-pdf) — `Page.onLoadSuccess`, `originalWidth`/`originalHeight`, v10 breaking changes
|
||||||
|
- `pdf-lib.js.org` official docs — `PDFDocument.load`, `drawText`, `drawRectangle`, `getForm().flatten()`
|
||||||
|
- `docs.dndkit.com` — `useDraggable`, `useDroppable`, `DragEndEvent`, `CSS.Translate.toString`
|
||||||
|
- `orm.drizzle.team/docs/custom-types` — `jsonb().$type<T>()` API
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- npmjs.com/package/@cantoo/pdf-lib — confirmed v2.6.1, published 18 days ago, active maintenance
|
||||||
|
- github.com/Hopding/pdf-lib/issues/1423 — confirmed abandonment of original pdf-lib
|
||||||
|
- pdfscripting.com/public/PDF-Page-Coordinates.cfm — PDF user space coordinate specification
|
||||||
|
- github.com/wojtekmaj/react-pdf/discussions/1632 — scaling issue on custom canvas over react-pdf page
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence — needs validation)
|
||||||
|
- Final absolute drop position calculation via `event.delta` in dnd-kit `onDragEnd` — community patterns, verify against actual dnd-kit behavior during implementation
|
||||||
|
- `@cantoo/pdf-lib` full API parity with original pdf-lib — stated drop-in but specific edge cases not verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — confirmed by official docs and npm registry
|
||||||
|
- Architecture: HIGH — derived from project conventions (STATE.md) and verified library APIs
|
||||||
|
- Coordinate math: HIGH — PDF spec is authoritative; formula verified from multiple official sources
|
||||||
|
- Pitfalls: MEDIUM-HIGH — most verified from official GitHub issues and docs; drop position calculation is MEDIUM (community patterns)
|
||||||
|
|
||||||
|
**Research date:** 2026-03-19
|
||||||
|
**Valid until:** 2026-04-19 (pdf-lib fork is active; dnd-kit is stable; react-pdf is stable)
|
||||||
Reference in New Issue
Block a user