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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user