Files
2026-03-19 23:36:47 -06:00

28 KiB
Raw Permalink Blame History

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:

npm install @dnd-kit/core @dnd-kit/utilities @cantoo/pdf-lib

Architecture Patterns

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.

// 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.

// 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.

// 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.

// 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.

// 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

// 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

// 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

// 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

// 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.comuseDraggable, useDroppable, DragEndEvent, CSS.Translate.toString
  • orm.drizzle.team/docs/custom-typesjsonb().$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)