+ {/* Field palette */}
+
+
Field palette
+
+ {(Object.keys(FIELD_DEFAULTS) as FieldType[])
+ .filter((t) => t !== 'FREE_SIGNATURE') // collapsed with SIGNATURE for the palette
+ .map((t) => {
+ const def = FIELD_DEFAULTS[t];
+ const Icon = def.icon;
+ return (
+ setPlacingType(t === placingType ? null : t)}
+ className={
+ 'w-full text-left text-xs px-2 py-1.5 rounded flex items-center gap-2 transition ' +
+ (placingType === t
+ ? 'bg-foreground text-background'
+ : 'hover:bg-background border border-transparent hover:border-border')
+ }
+ >
+
+ {def.label}
+
+ );
+ })}
+
+ {placingType && (
+
+ Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}.
+
+ )}
+
+
Recipients
+
+ {recipients.map((r, i) => (
+
+
+ {r.name || r.email || `#${r.signingOrder}`}
+
+ ))}
+
+
+
+ {/* PDF + overlay */}
+
+
+ setPageNumber((p) => Math.max(1, p - 1))}
+ aria-label="Previous page"
+ >
+
+
+
+ {pageNumber} / {numPages}
+
+ = numPages}
+ onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
+ aria-label="Next page"
+ >
+
+
+ {isDetecting && (
+
+ Auto-detecting fields…
+
+ )}
+
+ {fields.length} {fields.length === 1 ? 'field' : 'fields'} placed
+
+
+
+
{
+ if (!placingType) return;
+ if (!pageContainerRef.current) return;
+ // Bail when the click landed on an existing field (we
+ // handle those via the field's own onClick).
+ if ((e.target as HTMLElement).closest('[data-field-id]')) return;
+ placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
+ }}
+ >
+
setNumPages(n)}
+ loading={
+
+ Loading PDF…
+
+ }
+ >
+
+
+ {/* Overlay layer */}
+
+ {pageFields.map((field) => (
+ onSelectField(field.id)}
+ onUpdate={(patch) => updateField(field.id, patch)}
+ onRemove={() => removeField(field.id)}
+ />
+ ))}
+
+
+
+
+
+ {/* Side panel for selected field */}
+ {selectedFieldId && (
+
f.id === selectedFieldId)!}
+ recipients={recipients}
+ onUpdate={(patch) => updateField(selectedFieldId, patch)}
+ onRemove={() => removeField(selectedFieldId)}
+ onClose={() => onSelectField(null)}
+ />
+ )}
+
+ );
+}
+
+function FieldOverlay({
+ field,
+ selected,
+ recipients,
+ onSelect,
+ onUpdate,
+ onRemove,
+}: {
+ field: PlacedField;
+ selected: boolean;
+ recipients: Recipient[];
+ onSelect: () => void;
+ onUpdate: (patch: Partial) => void;
+ onRemove: () => void;
+}) {
+ const Icon = FIELD_DEFAULTS[field.type].icon;
+ const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length];
+ const recipient = recipients[field.recipientIndex];
+
+ // Drag handler — translate mouse-move pixels into percent deltas
+ // against the parent container's bounding rect.
+ function startDrag(e: React.MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ onSelect();
+ const container = (e.currentTarget.parentElement?.parentElement as HTMLElement) ?? null;
+ if (!container) return;
+ const rect = container.getBoundingClientRect();
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const startPageX = field.pageX;
+ const startPageY = field.pageY;
+
+ function onMove(ev: MouseEvent) {
+ const dxPct = ((ev.clientX - startX) / rect.width) * 100;
+ const dyPct = ((ev.clientY - startY) / rect.height) * 100;
+ onUpdate({
+ pageX: Math.max(0, Math.min(100 - field.pageWidth, startPageX + dxPct)),
+ pageY: Math.max(0, Math.min(100 - field.pageHeight, startPageY + dyPct)),
+ });
+ }
+ function onUp() {
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseup', onUp);
+ }
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseup', onUp);
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ onSelect();
+ }}
+ onMouseDown={startDrag}
+ className={
+ 'absolute pointer-events-auto cursor-move rounded border-2 text-xs flex items-center gap-1 px-1 ' +
+ (selected ? 'ring-2 ring-offset-1 ring-foreground' : '')
+ }
+ style={{
+ left: `${field.pageX}%`,
+ top: `${field.pageY}%`,
+ width: `${field.pageWidth}%`,
+ height: `${field.pageHeight}%`,
+ backgroundColor: color + '22',
+ borderColor: color,
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={`${FIELD_DEFAULTS[field.type].label} for ${recipient?.name ?? 'unassigned'}`}
+ >
+
+
+ {FIELD_DEFAULTS[field.type].label}
+
+ {selected && (
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="ml-auto rounded p-0.5 hover:bg-background/60"
+ aria-label="Delete field"
+ >
+
+
+ )}
+
+ );
+}
+
+function FieldSidePanel({
+ field,
+ recipients,
+ onUpdate,
+ onRemove,
+ onClose,
+}: {
+ field: PlacedField;
+ recipients: Recipient[];
+ onUpdate: (patch: Partial) => void;
+ onRemove: () => void;
+ onClose: () => void;
+}) {
+ return (
+
+
+
Field properties
+
+
+
+
+
+ Type
+ onUpdate({ type: v as FieldType })}>
+
+
+
+
+ {(Object.keys(FIELD_DEFAULTS) as FieldType[]).map((t) => (
+
+ {FIELD_DEFAULTS[t].label}
+
+ ))}
+
+
+
+
+ Recipient
+ onUpdate({ recipientIndex: Number(v) })}
+ >
+
+
+
+
+ {recipients.map((r, i) => (
+
+ #{r.signingOrder} {r.name || r.email}
+
+ ))}
+
+
+
+
+
+ Delete field
+
+
+ );
+}
diff --git a/src/components/interests/interest-contract-tab.tsx b/src/components/interests/interest-contract-tab.tsx
index 25354433..bc98ff75 100644
--- a/src/components/interests/interest-contract-tab.tsx
+++ b/src/components/interests/interest-contract-tab.tsx
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
+import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -168,16 +169,16 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
/>
)}
- {/* Upload-for-Documenso-signing dialog placeholder. The real
- dialog (PDF picker + recipient configurator + send button)
- is part of the larger custom-doc-upload service that's a
- follow-up. For now show a friendly "coming soon" card. */}
+ {/* Phase 4 — upload-for-Documenso-signing dialog. Multi-step
+ (file → recipients → fields → send) backed by the Phase 3
+ service. Auto-detect runs after the file lands; rep can
+ tweak placements before sending. */}
{uploadForSigningOpen && (
-
)}
@@ -381,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
);
}
-
-/**
- * Placeholder for the upload-for-Documenso-signing flow until the
- * full upload + recipient + field-placement service is shipped.
- * Intentional dead-end so reps know the path exists rather than
- * misclicking and getting confusing behaviour.
- */
-function ComingSoonDialog({
- open,
- onOpenChange,
- title,
- body,
-}: {
- open: boolean;
- onOpenChange: (next: boolean) => void;
- title: string;
- body: string;
-}) {
- if (!open) return null;
- return (
- onOpenChange(false)}
- >
-
e.stopPropagation()}
- >
-
{title}
-
{body}
-
- onOpenChange(false)} size="sm" variant="outline">
- Got it
-
-
-
-
- );
-}
diff --git a/src/components/interests/interest-reservation-tab.tsx b/src/components/interests/interest-reservation-tab.tsx
index 654f5df6..3463d018 100644
--- a/src/components/interests/interest-reservation-tab.tsx
+++ b/src/components/interests/interest-reservation-tab.tsx
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
+import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -171,16 +172,13 @@ export function InterestReservationTab({
/>
)}
- {/* Upload-for-Documenso-signing dialog placeholder. The real
- dialog (PDF picker + recipient configurator + send button)
- is part of the larger custom-doc-upload service that's a
- follow-up. For now show a friendly "coming soon" card. */}
+ {/* Phase 4 — upload-for-Documenso-signing dialog. */}
{uploadForSigningOpen && (
-
)}
@@ -384,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
);
}
-
-/**
- * Placeholder for the upload-for-Documenso-signing flow until the
- * full upload + recipient + field-placement service is shipped.
- * Intentional dead-end so reps know the path exists rather than
- * misclicking and getting confusing behaviour.
- */
-function ComingSoonDialog({
- open,
- onOpenChange,
- title,
- body,
-}: {
- open: boolean;
- onOpenChange: (next: boolean) => void;
- title: string;
- body: string;
-}) {
- if (!open) return null;
- return (
- onOpenChange(false)}
- >
-
e.stopPropagation()}
- >
-
{title}
-
{body}
-
- onOpenChange(false)} size="sm" variant="outline">
- Got it
-
-
-
-
- );
-}
diff --git a/src/lib/services/document-field-detector.ts b/src/lib/services/document-field-detector.ts
new file mode 100644
index 00000000..a6e19a74
--- /dev/null
+++ b/src/lib/services/document-field-detector.ts
@@ -0,0 +1,297 @@
+/**
+ * Phase 4c — Auto-detect anchor scanner.
+ *
+ * Scans a PDF for common signing-block keywords ("Signature:", "Date:",
+ * "Initials", a long run of underscores, etc.) and proposes Documenso
+ * field placements positioned right after the matched anchor. Output
+ * is in PERCENT coordinates so it lines up with the existing
+ * `DocumensoFieldPlacement` shape consumed by the Phase 3 service.
+ *
+ * Confidence calculation is conservative: an explicit keyword match
+ * scores higher than a generic underscore-run; the field-type-specific
+ * regexes are tried in priority order so a `"Date of Signature:"`
+ * anchor doesn't double-place as both DATE and SIGNATURE.
+ *
+ * This is intentionally pdf-content driven (text-extraction based) —
+ * the alternative (image-of-PDF + OCR) is the bigger berth-PDF parser
+ * tier-3 path; we keep this lightweight so it runs in <500ms on a
+ * 10-page contract.
+ */
+
+import type { DocumensoFieldType } from '@/lib/services/documenso-client';
+
+/** Result of detection, one entry per matched anchor. */
+export interface DetectedField {
+ type: DocumensoFieldType;
+ /** 1-indexed page number. */
+ pageNumber: number;
+ /** All four values are 0-100 percent of page dimensions. */
+ pageX: number;
+ pageY: number;
+ pageWidth: number;
+ pageHeight: number;
+ /** 0..1 — how sure the scanner is. */
+ confidence: number;
+ /** Verbatim anchor that triggered the detection (display + debug). */
+ anchorText: string;
+ /** Inferred recipient label ("Buyer", "Seller", "Client", "Witness",
+ * "Developer", "Notary", null). Phase 4d maps these to recipients
+ * by role/name. */
+ inferredRecipientLabel?: string | null;
+}
+
+/** Anchor → field-type pattern table. Order matters: earlier patterns
+ * win when two anchors overlap on the same text item (e.g. "Date of
+ * Signature" matches both DATE and SIGNATURE — DATE goes first because
+ * it's the more specific pattern). */
+interface AnchorPattern {
+ type: DocumensoFieldType;
+ /** Test against lower-cased anchor text. */
+ match: RegExp;
+ /** Suggested field box in PDF points (72 dpi). Converted to percent
+ * per-page after extraction. */
+ widthPt: number;
+ heightPt: number;
+ /** Bias added to the base confidence. Specific keywords get a bump
+ * over the generic underscore catch-all. */
+ confidenceBoost: number;
+}
+
+const ANCHOR_PATTERNS: AnchorPattern[] = [
+ // DATE — more specific than SIGNATURE for the common "Date of
+ // Signature:" case, so listed first.
+ {
+ type: 'DATE',
+ match: /(?:dated|date(?:\s+of\s+signature)?)[:\s_-]+/i,
+ widthPt: 80,
+ heightPt: 20,
+ confidenceBoost: 0.2,
+ },
+ // INITIALS — pre-empts NAME because "Initial:" is short and unique.
+ {
+ type: 'INITIALS',
+ match: /(?:^|\b)(?:initials?)[:\s_-]+/i,
+ widthPt: 50,
+ heightPt: 30,
+ confidenceBoost: 0.2,
+ },
+ // EMAIL — explicit email anchor.
+ {
+ type: 'EMAIL',
+ match: /(?:^|\b)e-?mail[:\s_-]+/i,
+ widthPt: 200,
+ heightPt: 20,
+ confidenceBoost: 0.2,
+ },
+ // NAME — printed/full name labels.
+ {
+ type: 'NAME',
+ match: /(?:^|\b)(?:printed\s*)?(?:full\s+)?name[:\s_-]+/i,
+ widthPt: 150,
+ heightPt: 20,
+ confidenceBoost: 0.15,
+ },
+ // SIGNATURE — broadest of the signing-block patterns.
+ {
+ type: 'SIGNATURE',
+ match: /(?:^|\b)(?:signature|sign\s*here|signed\s*by|signed\s*at)[:\s_-]+/i,
+ widthPt: 150,
+ heightPt: 30,
+ confidenceBoost: 0.2,
+ },
+ // SIGNATURE — explicit "X" mark followed by a blank line.
+ {
+ type: 'SIGNATURE',
+ match: /X\s*_{4,}/,
+ widthPt: 150,
+ heightPt: 30,
+ confidenceBoost: 0.15,
+ },
+ // Catch-all: a run of underscores not preceded by a more specific
+ // keyword (which would have matched above). Defaults to TEXT.
+ {
+ type: 'TEXT',
+ match: /_{8,}/,
+ widthPt: 200,
+ heightPt: 20,
+ confidenceBoost: 0,
+ },
+];
+
+/** Recipient labels we know how to match against. Kept in priority
+ * order so "Buyer Notary" wins NOTARY (more specific than BUYER on a
+ * notary-block tail). Each entry is lower-cased. */
+const RECIPIENT_LABELS: Array<{ label: string; aliases: string[] }> = [
+ { label: 'Notary', aliases: ['notary', 'witness'] },
+ { label: 'Witness', aliases: ['witness'] },
+ { label: 'Developer', aliases: ['developer', 'seller', 'vendor'] },
+ { label: 'Approver', aliases: ['approver', 'manager'] },
+ { label: 'Buyer', aliases: ['buyer', 'purchaser', 'client'] },
+ { label: 'Seller', aliases: ['seller', 'vendor'] },
+ { label: 'Client', aliases: ['client', 'customer'] },
+];
+
+/** A single text item returned by pdfjs-dist. The transform array
+ * encodes the position + scale of the text via PDF's affine matrix:
+ * `[scaleX, skewY, skewX, scaleY, translateX, translateY]`. We use
+ * `(translateX, translateY)` as the anchor's lower-left corner. */
+interface PdfTextItem {
+ str: string;
+ /** PDF affine [a, b, c, d, e, f]. (e, f) is position. */
+ transform: number[];
+ /** Item width in PDF user-space units. */
+ width?: number;
+ /** Item height — usually equals scaleY. */
+ height?: number;
+}
+
+interface PdfPageView {
+ pageNumber: number;
+ widthPt: number;
+ heightPt: number;
+ items: PdfTextItem[];
+}
+
+/**
+ * Detect signing-block fields in a PDF. Each detection points at the
+ * position immediately after the matched anchor text and is offset 5pt
+ * to the right so the placeholder doesn't visually overlap the
+ * keyword.
+ *
+ * Returns an empty array when the PDF has no extractable text (image-
+ * only scans). The caller should fall back to drag-place-manual in
+ * that case.
+ */
+export async function detectFields(pdfBuffer: Buffer): Promise {
+ const pages = await extractPdfPages(pdfBuffer);
+ const detected: DetectedField[] = [];
+
+ for (const page of pages) {
+ for (const item of page.items) {
+ const lower = item.str.toLowerCase();
+ // Skip if the item has no positional data — defensive against
+ // exotic PDF encodings.
+ if (!Array.isArray(item.transform) || item.transform.length < 6) continue;
+ const translateX = Number(item.transform[4]);
+ const translateY = Number(item.transform[5]);
+ if (!Number.isFinite(translateX) || !Number.isFinite(translateY)) continue;
+
+ for (const pattern of ANCHOR_PATTERNS) {
+ if (!pattern.match.test(lower)) continue;
+
+ // Place the field immediately after the anchor with a 5pt
+ // horizontal offset. The anchor's width is approximate; pdfjs
+ // sometimes gives a too-small width for short tokens so we
+ // floor at 30pt to avoid the field landing on top of the text.
+ const anchorWidthPt = Math.max(30, item.width ?? lower.length * 5);
+ const fieldXPt = translateX + anchorWidthPt + 5;
+ // PDF user-space origin is the lower-left; transform[5] is the
+ // baseline of the text so the field's lower-left also lives
+ // there. CSS/web origin is top-left — we keep the percent in
+ // PDF coordinates here because Documenso accepts both (the
+ // existing placeFields helper handles the conversion).
+ const fieldYPt = translateY;
+
+ const pageX = (fieldXPt / page.widthPt) * 100;
+ const pageY = (fieldYPt / page.heightPt) * 100;
+ const pageWidth = (pattern.widthPt / page.widthPt) * 100;
+ const pageHeight = (pattern.heightPt / page.heightPt) * 100;
+
+ // Hard-skip fields that would land off-page (defensive — a
+ // misparsed transform can blow up the coordinate space).
+ if (pageX < 0 || pageX > 95 || pageY < 0 || pageY > 95) continue;
+ if (pageWidth <= 0 || pageHeight <= 0) continue;
+
+ const recipientLabel = inferRecipient(page.items, item, translateX, translateY);
+
+ detected.push({
+ type: pattern.type,
+ pageNumber: page.pageNumber,
+ pageX,
+ pageY,
+ pageWidth,
+ pageHeight,
+ confidence: 0.5 + pattern.confidenceBoost,
+ anchorText: item.str.trim(),
+ inferredRecipientLabel: recipientLabel,
+ });
+ // First matching pattern wins for this item — earlier
+ // (more-specific) patterns shadow later ones.
+ break;
+ }
+ }
+ }
+ return detected;
+}
+
+/**
+ * Walk the page's other text items within ±100pt of the anchor and
+ * find a recipient-label keyword. Used to seed the recipient
+ * assignment side-panel; the rep can override.
+ */
+function inferRecipient(
+ items: PdfTextItem[],
+ anchor: PdfTextItem,
+ anchorX: number,
+ anchorY: number,
+): string | null {
+ const RADIUS = 100;
+ for (const candidate of items) {
+ if (candidate === anchor) continue;
+ if (!Array.isArray(candidate.transform) || candidate.transform.length < 6) continue;
+ const cx = Number(candidate.transform[4]);
+ const cy = Number(candidate.transform[5]);
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) continue;
+ if (Math.abs(cx - anchorX) > RADIUS) continue;
+ if (Math.abs(cy - anchorY) > RADIUS) continue;
+ const lower = candidate.str.toLowerCase();
+ for (const { label, aliases } of RECIPIENT_LABELS) {
+ if (aliases.some((alias) => lower.includes(alias))) return label;
+ }
+ }
+ return null;
+}
+
+/**
+ * Extract per-page text + page dimensions from a PDF buffer. Uses
+ * pdfjs-dist (the same library powering react-pdf in the dialog). We
+ * import it dynamically so the heavy native-bindings dep only loads
+ * when the detector actually runs.
+ *
+ * Returns an empty array if pdfjs fails to parse — the rep gets the
+ * manual placement flow without an error toast.
+ */
+export async function extractPdfPages(pdfBuffer: Buffer): Promise {
+ try {
+ // pdfjs-dist 5.x ships a legacy ESM build that works in Node + Next
+ // server bundles without the worker wiring needed in the browser.
+ const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
+ const data = new Uint8Array(pdfBuffer);
+ const loadingTask = pdfjsLib.getDocument({ data });
+ const pdf = await loadingTask.promise;
+ const pages: PdfPageView[] = [];
+ for (let i = 1; i <= pdf.numPages; i++) {
+ const page = await pdf.getPage(i);
+ const viewport = page.getViewport({ scale: 1 });
+ const content = await page.getTextContent();
+ const items = (content.items as Array).filter(isPdfTextItem);
+ pages.push({
+ pageNumber: i,
+ widthPt: viewport.width,
+ heightPt: viewport.height,
+ items,
+ });
+ }
+ return pages;
+ } catch {
+ // Image-only scans or corrupt PDFs land here. The dialog falls
+ // back to manual placement — no rep-facing error needed.
+ return [];
+ }
+}
+
+function isPdfTextItem(item: unknown): item is PdfTextItem {
+ if (!item || typeof item !== 'object') return false;
+ const i = item as Record;
+ return typeof i.str === 'string' && Array.isArray(i.transform);
+}
diff --git a/tests/unit/services/document-field-detector.test.ts b/tests/unit/services/document-field-detector.test.ts
new file mode 100644
index 00000000..0e9d58af
--- /dev/null
+++ b/tests/unit/services/document-field-detector.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, vi } from 'vitest';
+
+// Mock pdfjs-dist before importing the service. The detector calls
+// `import('pdfjs-dist/legacy/build/pdf.mjs')` dynamically; we stub the
+// module with a fake document whose pages return canned text items so
+// we can assert the anchor-matching + coordinate-conversion logic
+// without needing a real PDF.
+vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
+ getDocument: (_opts: unknown) => ({
+ promise: Promise.resolve({
+ numPages: 1,
+ getPage: async (_n: number) => ({
+ getViewport: ({ scale: _s }: { scale: number }) => ({
+ width: 595, // A4 in pt
+ height: 842,
+ }),
+ getTextContent: async () => ({
+ items: [
+ // Item 0: a signature anchor near the bottom-left
+ {
+ str: 'Signature: ',
+ transform: [1, 0, 0, 1, 50, 100],
+ width: 70,
+ },
+ // Item 1: a date anchor next to it
+ {
+ str: 'Date: ',
+ transform: [1, 0, 0, 1, 250, 100],
+ width: 40,
+ },
+ // Item 2: recipient label nearby
+ {
+ str: 'Buyer',
+ transform: [1, 0, 0, 1, 50, 130],
+ width: 40,
+ },
+ // Item 3: unrelated body text (should not match)
+ {
+ str: 'The parties hereby agree…',
+ transform: [1, 0, 0, 1, 50, 200],
+ width: 200,
+ },
+ ],
+ }),
+ }),
+ }),
+ }),
+}));
+
+import { detectFields } from '@/lib/services/document-field-detector';
+
+describe('detectFields', () => {
+ it('returns matches for known anchors with the right type + page', async () => {
+ const result = await detectFields(Buffer.from('%PDF-1.7'));
+ expect(result.length).toBeGreaterThanOrEqual(2);
+ const sig = result.find((r) => r.type === 'SIGNATURE');
+ const date = result.find((r) => r.type === 'DATE');
+ expect(sig).toBeDefined();
+ expect(date).toBeDefined();
+ expect(sig?.pageNumber).toBe(1);
+ expect(date?.pageNumber).toBe(1);
+ });
+
+ it('infers recipient label from nearby text', async () => {
+ const result = await detectFields(Buffer.from('%PDF-1.7'));
+ const sig = result.find((r) => r.type === 'SIGNATURE');
+ expect(sig?.inferredRecipientLabel).toBe('Buyer');
+ });
+
+ it('returns percent coordinates in [0, 100]', async () => {
+ const result = await detectFields(Buffer.from('%PDF-1.7'));
+ for (const f of result) {
+ expect(f.pageX).toBeGreaterThanOrEqual(0);
+ expect(f.pageX).toBeLessThanOrEqual(100);
+ expect(f.pageY).toBeGreaterThanOrEqual(0);
+ expect(f.pageY).toBeLessThanOrEqual(100);
+ expect(f.pageWidth).toBeGreaterThan(0);
+ expect(f.pageHeight).toBeGreaterThan(0);
+ }
+ });
+
+ it('attaches the anchor text + a confidence score', async () => {
+ const result = await detectFields(Buffer.from('%PDF-1.7'));
+ const sig = result.find((r) => r.type === 'SIGNATURE');
+ expect(sig?.anchorText).toMatch(/signature/i);
+ expect(sig?.confidence).toBeGreaterThan(0.5);
+ expect(sig?.confidence).toBeLessThanOrEqual(1);
+ });
+
+ it('does not match body text that lacks a signing-block keyword', async () => {
+ const result = await detectFields(Buffer.from('%PDF-1.7'));
+ // The "The parties hereby agree" item should not produce a TEXT
+ // detection (no underscore run, no keyword).
+ expect(result.find((r) => r.anchorText?.includes('parties'))).toBeUndefined();
+ });
+
+ it('gracefully returns [] when pdfjs throws', async () => {
+ // Force pdfjs to reject for this one call
+ const mod = await import('pdfjs-dist/legacy/build/pdf.mjs');
+ const orig = mod.getDocument;
+ (mod as unknown as { getDocument: typeof orig }).getDocument = () =>
+ ({ promise: Promise.reject(new Error('boom')) }) as ReturnType;
+ const result = await detectFields(Buffer.from('not-a-pdf'));
+ expect(result).toEqual([]);
+ (mod as unknown as { getDocument: typeof orig }).getDocument = orig;
+ });
+});