feat(12.1-01): replace positional text fill with field-ID-keyed lookup

- Remove AcroForm Strategy A (getForm/flatten) — no longer needed
- Remove positional sorting loop (textFields_sorted, remainingEntries, fieldConsumedKeys)
- Remove Strategy B top-of-page UUID stamp (unstampedEntries)
- Add Phase 12.1 field-ID-keyed loop: textFields[field.id] direct lookup
- Update JSDoc to document new keying strategy
This commit is contained in:
Chandler Copeland
2026-03-21 16:20:25 -06:00
parent 62ba448460
commit df02a1e3f7

View File

@@ -4,18 +4,16 @@ import type { SignatureFieldData } from '@/lib/db/schema';
import { getFieldType } from '@/lib/db/schema';
/**
* Fills AcroForm text fields and draws signature rectangles on a PDF.
* Draws signature/field placeholders and text values on a PDF.
* Uses atomic write: write to tmp path, verify %PDF header, rename to final path.
*
* Text fill strategy:
* 1. Attempt AcroForm field filling by field name (works when PDF has AcroForm fields).
* 2. For any entry that did not match an AcroForm field (or when there is no AcroForm at all),
* draw the key=value pairs as text directly on page 1 near the top margin.
* This ensures text fill data is ALWAYS visible in the output PDF.
* Text fill: textFields is keyed by SignatureFieldData.id (UUID). Each text-type
* placed field box has its value looked up directly by field ID and drawn at
* the field's coordinates.
*
* @param srcPath - Absolute path to original PDF
* @param destPath - Absolute path to write prepared PDF (e.g. {docId}_prepared.pdf)
* @param textFields - Key/value map: { fieldNameOrLabel: value } (agent-provided)
* @param textFields - Key/value map: { [fieldId (UUID)]: value } (agent-provided)
* @param sigFields - Signature field array from SignatureFieldData[]
*/
export async function preparePdf(
@@ -43,62 +41,13 @@ export async function preparePdf(
agentInitialsImage = await pdfDoc.embedPng(agentInitialsData);
}
// Track which text field entries were successfully written via AcroForm so that
// the fallback text stamp only shows entries that were NOT already embedded.
const acroFilledKeys = new Set<string>();
let hasAcroForm = false;
// Strategy A: fill existing AcroForm fields by name.
// form.flatten() MUST happen before drawing rectangles — if reversed,
// the AcroForm overlay obscures the drawn rectangles.
try {
const form = pdfDoc.getForm();
hasAcroForm = true;
for (const [fieldName, value] of Object.entries(textFields)) {
try {
form.getTextField(fieldName).setText(value);
acroFilledKeys.add(fieldName);
} catch {
// Field name not found in AcroForm — will fall through to Strategy B
}
}
form.flatten();
} catch {
// No AcroForm in this PDF — all entries fall through to Strategy B
hasAcroForm = false;
}
// Bug 3 fix: assign textFillData values to placed `text` type field boxes in reading
// order (page asc, y desc within page = top-to-bottom in PDF user space). Each text
// field box consumes one textFillData entry so the value is rendered AT the correct
// position on the document rather than only via Strategy B at the top of page 1.
//
// Build an ordered list of textFillData entries not already handled by AcroForm (Strategy A).
const remainingEntries: Array<[string, string]> = Object.entries(textFields).filter(
([key]) => !acroFilledKeys.has(key),
);
// Sort placed `text` fields by page asc, then y desc (topmost field first in reading order).
const textFields_sorted = sigFields
.filter((f) => getFieldType(f) === 'text')
.sort((a, b) => a.page !== b.page ? a.page - b.page : b.y - a.y);
// Track which textFillData keys are consumed by field-box rendering so Strategy B
// only stamps any leftover entries (keys that had no corresponding placed text field).
const fieldConsumedKeys = new Set<string>();
textFields_sorted.forEach((field, idx) => {
const entry = remainingEntries[idx];
if (!entry) return; // More text fields than textFillData entries — nothing to draw
const [key, value] = entry;
if (!value) return; // Skip blank values — leave the box visually empty
// Phase 12.1: Draw text values at placed text field boxes — keyed by field ID (UUID)
for (const field of sigFields) {
if (getFieldType(field) !== 'text') continue;
const value = textFields[field.id];
if (!value) continue;
const page = pages[field.page - 1];
if (!page) return;
// Draw value text at the field's bottom-left corner with 4pt padding.
// Font size is capped to fit within the field height (min 6pt, max 11pt).
if (!page) continue;
const fontSize = Math.max(6, Math.min(11, field.height - 4));
page.drawText(value, {
x: field.x + 4,
@@ -107,43 +56,6 @@ export async function preparePdf(
font: helvetica,
color: rgb(0.05, 0.05, 0.05),
});
fieldConsumedKeys.add(key);
});
// Strategy B: stamp any un-filled text field entries directly onto page 1.
// This guarantees that text fill data is always reflected in the output PDF
// even when the source PDF has no AcroForm fields (e.g. scanned/flat PDFs).
// Only entries NOT consumed by field-box rendering are stamped here.
const unstampedEntries = remainingEntries.filter(
([key]) => !fieldConsumedKeys.has(key),
);
if (unstampedEntries.length > 0) {
const firstPage = pages[0];
if (firstPage) {
const { height: pageHeight } = firstPage.getSize();
// Start near the top of the page (points from bottom, since PDF Y=0 is bottom)
const startY = pageHeight - 60; // 60pt (~0.83 inch) from top
const lineHeight = 14;
const labelColor = rgb(0.2, 0.2, 0.2);
const valueColor = rgb(0.05, 0.05, 0.55);
unstampedEntries.forEach(([key, value], index) => {
const y = startY - index * lineHeight;
if (y < 10) return; // Don't draw below bottom margin
firstPage.drawText(`${key}: ${value}`, {
x: 10,
y,
size: 10,
font: helvetica,
color: hasAcroForm ? valueColor : labelColor,
// Semi-transparent background note: @cantoo/pdf-lib drawText has no background
// option; the text is drawn directly over existing content.
});
});
}
}
// Draw field placeholders — rendering varies by field type
@@ -191,7 +103,7 @@ export async function preparePdf(
// No placeholder drawn — actual signing date stamped at POST time in route.ts
} else if (fieldType === 'text') {
// Text value drawn at field coordinates above (Bug 3 fix — textFields_sorted loop).
// Text value drawn in field-ID loop above (Phase 12.1).
// No additional marker drawn here.
} else if (fieldType === 'agent-signature') {