fix(05-04): always stamp text fill data into prepared PDF

Strategy A still attempts AcroForm filling by field name (matching
fields in the PDF's form dict). Strategy B is now a mandatory fallback:
any text field entries that did not match an AcroForm field (or when
the PDF has no AcroForm at all) are drawn as 'key: value' text lines
near the top of page 1 using @cantoo/pdf-lib drawText.

This ensures text fill data supplied in the PreparePanel is always
visible in the output PDF regardless of whether the source PDF was
built with interactive form fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-20 00:22:17 -06:00
parent 05915aa562
commit ef10dd5089

View File

@@ -6,6 +6,12 @@ import type { SignatureFieldData } from '@/lib/db/schema';
* Fills AcroForm text fields and draws signature rectangles 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.
*
* @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)
@@ -22,20 +28,63 @@ export async function preparePdf(
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
// Strategy A: fill existing AcroForm fields by name
// form.flatten() MUST happen before drawing rectangles
// 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 — silently skip
// Field name not found in AcroForm — will fall through to Strategy B
}
}
form.flatten();
} catch {
// No AcroForm in this PDF — skip to rectangle drawing
// No AcroForm in this PDF — all entries fall through to Strategy B
hasAcroForm = false;
}
// 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).
const unstampedEntries = Object.entries(textFields).filter(
([key]) => !acroFilledKeys.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 - 20;
const lineHeight = 12;
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: 8,
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 signature field placeholders (blue rectangle + "Sign Here" label)