Files
red/.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md
2026-03-19 23:36:47 -06:00

578 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)