feat(05-01): add preparePdf utility and fields/prepare API routes
- Installed @cantoo/pdf-lib for server-side PDF mutation - Created src/lib/pdf/prepare-document.ts with preparePdf function using atomic tmp->rename write pattern - form.flatten() called before drawing signature rectangles - Created GET/PUT /api/documents/[id]/fields routes for signature field storage - Created POST /api/documents/[id]/prepare route that calls preparePdf and transitions status to Sent - Fixed pre-existing null check error in scripts/debug-inspect2.ts (Rule 3: blocking build) - Build compiles successfully with 2 new API routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
77
teressa-copeland-homes/src/lib/pdf/prepare-document.ts
Normal file
77
teressa-copeland-homes/src/lib/pdf/prepare-document.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
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.
|
||||
*
|
||||
* @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 sigFields - Signature field array from SignatureFieldData[]
|
||||
*/
|
||||
export async function preparePdf(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
textFields: Record<string, string>,
|
||||
sigFields: SignatureFieldData[],
|
||||
): Promise<void> {
|
||||
const pdfBytes = await readFile(srcPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
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
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
for (const [fieldName, value] of Object.entries(textFields)) {
|
||||
try {
|
||||
form.getTextField(fieldName).setText(value);
|
||||
} catch {
|
||||
// Field name not found in AcroForm — silently skip
|
||||
}
|
||||
}
|
||||
form.flatten();
|
||||
} catch {
|
||||
// No AcroForm in this PDF — skip to rectangle drawing
|
||||
}
|
||||
|
||||
// Draw signature field placeholders (blue rectangle + "Sign Here" label)
|
||||
for (const field of sigFields) {
|
||||
const page = pages[field.page - 1]; // page is 1-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),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
const modifiedBytes = await pdfDoc.save();
|
||||
|
||||
// Atomic write: tmp → rename (prevents partial writes from corrupting the only copy)
|
||||
const tmpPath = `${destPath}.tmp`;
|
||||
await writeFile(tmpPath, modifiedBytes);
|
||||
|
||||
// Verify output is a valid PDF (check magic bytes)
|
||||
const magic = modifiedBytes.slice(0, 4);
|
||||
const header = Buffer.from(magic).toString('ascii');
|
||||
if (!header.startsWith('%PDF')) {
|
||||
throw new Error('preparePdf: output is not a valid PDF (magic bytes check failed)');
|
||||
}
|
||||
|
||||
await rename(tmpPath, destPath);
|
||||
}
|
||||
Reference in New Issue
Block a user