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:
180
teressa-copeland-homes/package-lock.json
generated
180
teressa-copeland-homes/package-lock.json
generated
@@ -8,7 +8,9 @@
|
||||
"name": "teressa-copeland-homes",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.6.3",
|
||||
"@vercel/blob": "^2.3.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -23,6 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
@@ -32,6 +35,7 @@
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
@@ -319,6 +323,21 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cantoo/pdf-lib": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.3.tgz",
|
||||
"integrity": "sha512-oscThzfl9M+/8lWM4Ia5ZaEhKx8MffqSm05hDm5nDMcOTvGPA4j/lGLxYj9NVZoJqvgGMkoSMvKfm/acsoaAPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"node-html-better-parser": ">=1.4.0",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
@@ -2439,6 +2458,24 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2737,6 +2774,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/adm-zip": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
||||
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
@@ -3421,6 +3468,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -3898,11 +3954,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3915,9 +3983,18 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3947,6 +4024,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -5439,6 +5522,22 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -5509,6 +5608,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-async-function": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
||||
@@ -6704,6 +6809,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-better-parser": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
@@ -6920,6 +7034,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7028,6 +7148,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7662,6 +7829,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"scrape:forms": "DOTENV_CONFIG_PATH=.env.local npx tsx scripts/scrape-skyslope-forms.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.6.3",
|
||||
"@vercel/blob": "^2.3.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -30,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
|
||||
@@ -19,7 +19,7 @@ config({ path: path.resolve(process.cwd(), '.env.local') });
|
||||
for (const el of loginLinks) {
|
||||
const text = await el.textContent().catch(() => '');
|
||||
const href = await el.getAttribute('href').catch(() => '');
|
||||
if (/login|sign.?in|log.?in|get started|access/i.test(text + href)) {
|
||||
if (/login|sign.?in|log.?in|get started|access/i.test((text ?? '') + (href ?? ''))) {
|
||||
console.log('Found login element:', text?.trim().slice(0,60), href?.slice(0,80));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { documents } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { SignatureFieldData } from '@/lib/db/schema';
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) });
|
||||
if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
return Response.json(doc.signatureFields ?? []);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const fields: SignatureFieldData[] = await req.json();
|
||||
|
||||
const [updated] = await db
|
||||
.update(documents)
|
||||
.set({ signatureFields: fields })
|
||||
.where(eq(documents.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
return Response.json(updated.signatureFields ?? []);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { documents } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { preparePdf } from '@/lib/pdf/prepare-document';
|
||||
import path from 'node:path';
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json() as {
|
||||
textFillData?: Record<string, string>;
|
||||
assignedClientId?: string;
|
||||
};
|
||||
|
||||
const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) });
|
||||
if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
if (!doc.filePath) return Response.json({ error: 'Document has no PDF file' }, { status: 422 });
|
||||
|
||||
const srcPath = path.join(UPLOADS_DIR, doc.filePath);
|
||||
// Prepared variant stored next to original with _prepared suffix
|
||||
const preparedRelPath = doc.filePath.replace(/\.pdf$/, '_prepared.pdf');
|
||||
const destPath = path.join(UPLOADS_DIR, preparedRelPath);
|
||||
|
||||
// Path traversal guard
|
||||
if (!destPath.startsWith(UPLOADS_DIR)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const sigFields = (doc.signatureFields as import('@/lib/db/schema').SignatureFieldData[]) ?? [];
|
||||
const textFields = body.textFillData ?? {};
|
||||
|
||||
await preparePdf(srcPath, destPath, textFields, sigFields);
|
||||
|
||||
const [updated] = await db
|
||||
.update(documents)
|
||||
.set({
|
||||
preparedFilePath: preparedRelPath,
|
||||
textFillData: body.textFillData ?? null,
|
||||
assignedClientId: body.assignedClientId ?? doc.assignedClientId ?? null,
|
||||
status: 'Sent',
|
||||
sentAt: new Date(),
|
||||
})
|
||||
.where(eq(documents.id, id))
|
||||
.returning();
|
||||
|
||||
return Response.json(updated);
|
||||
}
|
||||
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