| name | document-generation-pdf |
| description | Generate, fill, and assemble PDF documents at scale. Handles legal forms, contracts, invoices, certificates. Supports form filling (pdf-lib), template rendering (Puppeteer, LaTeX), digital signatures (DocuSign), and document assembly. Use for legal tech, HR automation, invoice generation. Activate on "PDF generation", "form filling", "document automation", "digital signatures". NOT for simple PDF viewing, basic file conversion, or OCR text extraction. |
| allowed-tools | Read,Write,Edit,Bash(npm:*,latex*) |
| metadata | {"category":"Content & Writing","tags":["document","generation","pdf","pdf-generation","form-filling"],"pairs-with":[{"skill":"national-expungement-expert","reason":"Expungement court filings require automated PDF form generation at scale"},{"skill":"2026-legal-research-agent","reason":"Legal research outputs are formatted into filing documents via PDF generation"},{"skill":"cv-creator","reason":"Resume PDF generation shares template rendering and typography patterns"},{"skill":"technical-writer","reason":"Technical documentation exports to PDF for offline distribution"}]} |
Document Generation & PDF Automation
Expert in generating, filling, and assembling PDF documents programmatically for legal, HR, and business workflows.
When to Use
✅ Use for:
- Legal form automation (expungement, immigration, contracts)
- Invoice/receipt generation at scale
- Certificate creation (completion, participation, awards)
- Contract assembly from templates
- Government form filling (IRS, court filings)
- Multi-document packet creation
❌ NOT for:
- Simple PDF viewing (use browser or pdf.js)
- Basic file conversion (use online tools)
- OCR text extraction (use Tesseract.js or AWS Textract)
- PDF editing by hand (use Adobe Acrobat)
Technology Selection
pdf-lib vs Puppeteer vs LaTeX
| Feature | pdf-lib | Puppeteer | LaTeX |
|---|
| Form filling | ✅ Native | ❌ Complex | ❌ No |
| Template rendering | ❌ No | ✅ HTML/CSS | ✅ Templates |
| Performance (1000 PDFs) | 5s | 60s | 30s |
| File size | Small | Medium | Small |
| Signature fields | ✅ Yes | ❌ No | ❌ No |
| Best for | Government forms | Invoices, reports | Academic papers |
Timeline:
- 2000s: LaTeX for academic documents
- 2010: PDFKit (Node.js) for generation
- 2017: Puppeteer for HTML → PDF
- 2019: pdf-lib for pure JS form filling
- 2024: pdf-lib is gold standard for forms
Decision tree:
Need to fill existing form? → pdf-lib
Need complex layouts? → Puppeteer (HTML/CSS)
Need academic formatting? → LaTeX
Need to merge PDFs? → pdf-lib
Need digital signatures? → pdf-lib + DocuSign API
Common Anti-Patterns
Anti-Pattern 1: Using Puppeteer for Simple Form Filling
Novice thinking: "I'll use Puppeteer for everything, it's versatile"
Problem: 12x slower, 10x more memory, can't preserve form fields.
Wrong approach:
import puppeteer from 'puppeteer';
async function fillForm(data: FormData): Promise<Buffer> {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(`file://${pdfPath}`);
await page.evaluate((data) => {
}, data);
const pdf = await page.pdf();
await browser.close();
return pdf;
}
Why wrong:
- Browser overhead (200MB+ RAM per instance)
- Can't access native PDF form fields
- Loses interactive form capabilities
- 12x slower than pdf-lib
Correct approach:
import { PDFDocument } from 'pdf-lib';
async function fillForm(templatePath: string, data: FormData): Promise<Uint8Array> {
const existingPdfBytes = await fs.readFile(templatePath);
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const form = pdfDoc.getForm();
form.getTextField('applicant_name').setText(data.name);
form.getTextField('case_number').setText(data.caseNumber);
form.getTextField('date_of_birth').setText(data.dob);
if (data.hasPriorConvictions) {
form.getCheckBox('prior_convictions').check();
}
form.getDropdown('state').select(data.);
form.();
pdfDoc.();
}
Performance comparison (1000 PDFs):
- Puppeteer: 60 seconds, 4GB RAM
- pdf-lib: 5 seconds, 200MB RAM
Timeline context:
- 2017: Puppeteer released, everyone used it for PDFs
- 2019: pdf-lib released, proper form handling
- 2024: pdf-lib is standard for government forms
Anti-Pattern 2: Not Flattening Forms
Problem: Users can edit filled forms, causing data inconsistencies.
Wrong approach:
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const form = pdfDoc.getForm();
form.getTextField('name').setText('John Doe');
const pdfBytes = await pdfDoc.save();
Why wrong:
- User can modify official documents
- Data doesn't match database
- Violates document integrity
Correct approach:
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const form = pdfDoc.getForm();
form.getTextField('name').setText('John Doe');
form.flatten();
const pdfBytes = await pdfDoc.save();
When NOT to flatten:
- Draft documents (user needs to review/edit)
- Multi-step workflows (partial completion)
- Templates for users to fill manually
Anti-Pattern 3: Generating PDFs from HTML Without Page Breaks
Novice thinking: "HTML → PDF is easy with Puppeteer"
Problem: Content splits mid-sentence across pages.
Wrong approach:
const html = `
<div class="contract">
<h1>Mutual Agreement</h1>
<p>Long paragraph that might split across pages...</p>
<section>
<h2>Terms and Conditions</h2>
<ol>
<li>Term 1 that could get cut off...</li>
<li>Term 2...</li>
</ol>
</section>
</div>
`;
const pdf = await page.pdf({ format: 'A4' });
Correct approach:
const html = `
<style>
@media print {
.page-break { page-break-after: always; }
.avoid-break { page-break-inside: avoid; }
h1, h2, h3 {
page-break-after: avoid;
page-break-inside: avoid;
}
section {
page-break-inside: avoid;
}
}
</style>
<div class="contract">
<section class="avoid-break">
<h1>Mutual Agreement</h1>
<p>This entire section stays together...</p>
</section>
<div class="page-break"></div>
<section class="avoid-break">
<h2>Terms and Conditions</h2>
<ol>
<li>Term 1</li>
<li>Term 2</li>
</ol>
</section>
</div>
`;
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '1in',
right: '1in',
bottom: '1in',
left: '1in'
}
});
CSS print properties:
page-break-before: always - Force new page before element
page-break-after: always - Force new page after element
page-break-inside: avoid - Keep element together
Anti-Pattern 4: Not Handling Signature Fields
Problem: Signature fields aren't clickable in generated PDFs.
Wrong approach:
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const signatureImage = await pdfDoc.embedPng(signaturePngBytes);
const page = pdfDoc.getPage(0);
page.drawImage(signatureImage, {
x: 100,
y: 100,
width: 200,
height: 50
});
await pdfDoc.save();
Why wrong:
- Not legally recognized (just an image)
- Can't use DocuSign/Adobe Sign
- No signature metadata (who, when)
Correct approach 1: Create signature field (for DocuSign)
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const form = pdfDoc.getForm();
const signatureField = form.createTextField('applicant_signature');
signatureField.addToPage(pdfDoc.getPage(0), {
x: 100,
y: 100,
width: 200,
height: 50
});
signatureField.updateWidgets({
borderWidth: 1,
borderColor: { type: 'RGB', red: 0, green: 0, blue: 0 }
});
await pdfDoc.save();
Correct approach 2: DocuSign API integration
import { ApiClient, EnvelopesApi } from 'docusign-esign';
async function sendForSignature(pdfBytes: Uint8Array, signerEmail: string) {
const apiClient = new ApiClient();
apiClient.setBasePath('https://demo.docusign.net/restapi');
const envelopesApi = new EnvelopesApi(apiClient);
const envelope = {
emailSubject: 'Please sign: Expungement Petition',
documents: [{
documentBase64: Buffer.from(pdfBytes).toString('base64'),
name: 'Petition.pdf',
fileExtension: 'pdf',
documentId: '1'
}],
recipients: {
signers: [{
email: signerEmail,
name: 'John Doe',
recipientId: '1',
tabs: {
signHereTabs: [{
documentId: '1',
pageNumber: '1',
: ,
:
}]
}
}]
},
:
};
envelopesApi.(accountId, { : envelope });
}
Anti-Pattern 5: Storing Filled PDFs Without Encryption
Problem: Sensitive legal documents stored in plain text.
Wrong approach:
const pdfBytes = await pdfDoc.save();
await fs.writeFile(`./documents/${caseId}.pdf`, pdfBytes);
Why wrong:
- Legal/medical data exposed
- HIPAA/GDPR violations
- Data breach liability
Correct approach 1: Encrypt at rest
const pdfBytes = await pdfDoc.save({
userPassword: generateSecurePassword(),
ownerPassword: process.env.PDF_OWNER_PASSWORD,
permissions: {
printing: 'highResolution',
modifying: false,
copying: false,
annotating: false,
fillingForms: false,
contentAccessibility: true,
documentAssembly: false
}
});
await fs.writeFile(`./documents/${caseId}.pdf`, pdfBytes);
Correct approach 2: Store in encrypted storage (S3 with SSE)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3Client = new S3Client({ region: 'us-east-1' });
await s3Client.send(new PutObjectCommand({
Bucket: 'expungement-documents',
Key: `cases/${caseId}/petition.pdf`,
Body: pdfBytes,
ServerSideEncryption: 'AES256',
Metadata: {
caseId: caseId,
documentType: 'petition',
generatedAt: new Date().toISOString()
},
ACL: 'private'
}));
await db.documents.insert({
case_id: caseId,
s3_url: encrypt(`s3://expungement-documents/cases/${caseId}/petition.pdf`),
created_at: new Date()
});
Compliance requirements:
- HIPAA: Encryption at rest + in transit, access logging
- GDPR: Right to deletion, data minimization
- State bar rules: Attorney-client privilege protection
Production Checklist
□ Form fields filled correctly (test with validation)
□ Forms flattened after filling (non-editable)
□ Page breaks controlled (no mid-sentence splits)
□ Signature fields created (for DocuSign/Adobe Sign)
□ PDFs encrypted at rest (S3 SSE or user password)
□ Access logged (who viewed/downloaded)
□ Auto-deletion scheduled (retention policy)
□ Fonts embedded (cross-platform compatibility)
□ File size optimized (<5MB per document)
□ Batch generation tested (1000+ PDFs)
When to Use vs Avoid
| Scenario | Appropriate? |
|---|
| Fill 50+ government forms | ✅ Yes - automate with pdf-lib |
| Generate invoices from template | ✅ Yes - Puppeteer from HTML |
| Create certificates at scale | ✅ Yes - pdf-lib or LaTeX |
| Assemble multi-doc packets | ✅ Yes - pdf-lib merge |
| View PDFs in browser | ❌ No - use pdf.js or browser |
| Edit PDF by hand | ❌ No - use Adobe Acrobat |
| Extract text with OCR | ❌ No - use Tesseract/Textract |
Flat PDF Overlay at Scale (Government Forms)
Many government court forms are flat PDFs (no fillable fields). You MUST overlay text at precise X,Y coordinates. Never generate forms from scratch - courts will reject them.
The Three-Tier Strategy
1. FILLABLE PDF → Use form.getTextField().setText() (best)
2. FLAT PDF + COORDINATES → Draw text overlays at X,Y positions (this section)
3. NO TEMPLATE EXISTS → Emergency fallback with warning banner (worst)
Anti-Pattern 6: Generating Forms from Scratch
Novice thinking: "The PDF doesn't have form fields, I'll just create a new PDF"
Problem: Courts reject non-official forms. Your custom layout won't match official documents.
Wrong approach:
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
page.drawText('IN THE CIRCUIT COURT OF...', { x: 200, y: 720 });
page.drawText(`Defendant: ${data.name}`, { x: 100, y: 600 });
Correct approach: Draw on top of official template
const templateBytes = await fs.readFile('public/forms/OR-set-aside-motion.pdf');
const pdfDoc = await PDFDocument.load(templateBytes);
const page = pdfDoc.getPage(3);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
page.drawText(data.county.toUpperCase(), { x: 310, y: 700, size: 11, font });
page.drawText(data.caseNumber, { x: 432, y: 655, size: 10, font });
page.drawText(data.fullName, { x: 72, y: 568, size: 11, font });
Measuring Coordinates at Scale
Problem: Manually measuring X,Y for 50 states × 10+ forms = impossible.
Solution 1: Visual Coordinate Picker Tool
Use pdf-coordinates - click on PDF to capture X,Y:
- Upload PDF, click on blank fields
- Select coordinate system: Bottom-Left (PDF standard)
- Export annotated PDF with coordinate labels
- Works offline, no data upload
Solution 2: JSON Configuration (Zerodha Pattern)
Define coordinates in JSON config, not code:
{
"templatePath": "/forms/or/OR-criminal-set-aside.pdf",
"pageIndex": 3,
"fields": [
{
"name": "county",
"x": 310,
"y": 700,
"fontSize": 11,
"maxWidth": 120,
"transform": "uppercase"
},
{
"name": "caseNumber",
"x": 432,
"y": 655,
"fontSize": 10,
"maxWidth": 100,
"maxChars": 12
},
{
"name": "fullName",
"x": 72,
"y": 568,
"fontSize": 11,
"maxWidth": 250,
"shrinkToFit": true
}
]
}
Solution 3: Debug Grid Overlay
Add temporary grid during development:
async function drawDebugGrid(page: PDFPage, font: PDFFont) {
const { width, height } = page.getSize();
for (let x = 0; x <= width; x += 50) {
page.drawLine({
start: { x, y: 0 },
end: { x, y: height },
thickness: 0.2,
color: rgb(0.9, 0.9, 0.9),
});
if (x % 100 === 0) {
page.drawText(String(x), { x: x + 2, y: 5, size: 6, font });
}
}
}
Text Overflow Handling
Problem: Long names overflow into adjacent fields or get cut off.
Three overflow strategies:
function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, maxChars - 2) + '..';
}
function shrinkToFit(text: string, maxWidthPts: number, startSize: number): number {
let fontSize = startSize;
const charWidth = (size: number) => size * 0.52;
while (fontSize > 7 && text.length * charWidth(fontSize) > maxWidthPts) {
fontSize -= 0.5;
}
return fontSize;
}
page.drawText(longAddress, {
x: 100,
y: 500,
size: 9,
font,
maxWidth: 200,
: ,
});
Field input constraints - Prevent overflow at data collection:
export const FIELD_CONSTRAINTS = {
fullName: { maxLength: 35 },
county: { maxLength: 12 },
caseNumber: { maxLength: 12 },
agency: { maxLength: 28 },
charges: { maxLength: 45 },
} as const;
Production Workflow for Multi-State Forms
1. DOWNLOAD official forms
└─ Store in /public/forms/{state}/ with metadata.json
2. INSPECT each form
└─ Run pdf-lib inspection to check: fillable? how many fields?
3. CATEGORIZE forms
├─ Fillable → Map field names in field-mappings/{state}.ts
└─ Flat → Measure coordinates with pdf-coordinates tool
4. CREATE JSON config for flat PDFs
└─ One JSON file per form with all X,Y coordinates
5. TEST with debug grid
└─ Generate test PDF with colored text + coordinate labels
6. QA visual verification
└─ Compare filled PDF against original blank form
7. DOCUMENT constraints
└─ Export maxLength/maxWidth limits for form inputs
Legal Filing Requirements
Court e-filing systems require:
- ✅ Flattened forms (no interactive fields)
- ✅ No digital signature metadata (DocuSign breaks e-filing)
- ✅ Standard fonts embedded (Helvetica, Times)
- ✅ File under 10MB
- ✅ Exact match to official form layout
Flattening for courts:
const form = pdfDoc.getForm();
if (form.getFields().length > 0) {
form.flatten();
}
const pdfBytes = await pdfDoc.save();
Coordinate System Reference
PDF coordinate system (pdf-lib default):
- Origin: Bottom-left corner (0, 0)
- X increases: Left → Right
- Y increases: Bottom → Top
- Units: Points (72 points = 1 inch)
- Letter size: 612 x 792 points (8.5" x 11")
To convert from top-left origin (e.g., Adobe Acrobat display):
y_pdf = 792 - y_topLeft
Common positions (Letter size):
- Top margin: y = 720-750
- Header area: y = 700-750
- Body start: y = 650-700
- Left margin: x = 50-72
- Right edge: x = 540-560
- Bottom margin: y = 50-72
References
/references/pdf-lib-guide.md - Form filling, field types, flattening, encryption
/references/puppeteer-templates.md - HTML templates, page breaks, styling for print
/references/document-assembly.md - Merging PDFs, packet creation, watermarks
- pdf-coordinates - Visual X,Y coordinate picker
- Zerodha pdf_text_overlay - JSON config pattern
Scripts
scripts/form_filler.ts - Fill PDF forms from JSON data, batch processing
scripts/document_assembler.ts - Merge multiple PDFs, add cover pages, watermarks
scripts/generate-test-overlay-pdf.ts - Test overlay coordinates with debug grid
This skill guides: PDF generation | Form filling | Document automation | Digital signatures | pdf-lib | Puppeteer | LaTeX | DocuSign | Flat PDF overlay | Coordinate mapping