[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a PDF Template Engine

Build a PDF Template Engine

Build a PDF generation system with reusable templates, dynamic data binding, multi-page layouts, headers/footers, table rendering, and batch generation for invoices, reports, and contracts.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor
agent@terminalskills — playground
full playground →

Try this use case on your own files

simulated preview
$
$

real run in an isolated sandbox · files auto-deleted after 7 days · nothing touches your machine

Skills stack · 4 skills

Avg quality 95/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Petra leads engineering at a 20-person legal-tech company. They generate 2,000 contracts per month. Each contract is assembled manually in Word, exported to PDF, and emailed. Changing the company address means editing 15 templates. Dynamic data (client name, dates, amounts) is copy-pasted — typos happen in 5% of documents. They tried puppeteer-based PDF generation but it's slow (8 seconds per PDF) and uses 500MB RAM per instance. They need a template system that generates PDFs fast, handles multi-page layouts, and lets non-developers create templates.

Step 1: Build the PDF Engine

typescript
// src/pdf/engine.ts — PDF generation with templates, data binding, and batch processing
import PDFDocument from "pdfkit";
import { pool } from "../db";
import { Redis } from "ioredis";
import { Readable } from "node:stream";

const redis = new Redis(process.env.REDIS_URL!);

interface PDFTemplate {
  id: string;
  name: string;
  type: "invoice" | "contract" | "report" | "receipt" | "certificate";
  layout: {
    pageSize: "A4" | "Letter" | "Legal";
    margins: { top: number; bottom: number; left: number; right: number };
    orientation: "portrait" | "landscape";
  };
  header: TemplateSection | null;
  footer: TemplateSection | null;
  sections: TemplateSection[];
  styles: {
    fontFamily: string;
    primaryColor: string;
    accentColor: string;
    fontSize: number;
    lineHeight: number;
  };
  variables: Array<{ name: string; type: string; required: boolean; default?: any }>;
}

interface TemplateSection {
  type: "text" | "table" | "image" | "spacer" | "divider" | "signature" | "pagebreak";
  content?: string;            // supports {{variable}} placeholders
  style?: {
    fontSize?: number;
    bold?: boolean;
    italic?: boolean;
    alignment?: "left" | "center" | "right";
    color?: string;
    marginTop?: number;
    marginBottom?: number;
  };
  table?: {
    headers: string[];
    widths: number[];          // column widths as percentages
    dataKey: string;           // variable name containing row data
    rowTemplate: string[];     // {{item.field}} for each column
    totalRow?: string[];       // optional totals row
    striped?: boolean;
  };
  image?: {
    urlKey: string;            // variable name containing image URL
    width?: number;
    height?: number;
    alignment?: "left" | "center" | "right";
  };
  condition?: string;          // {{#if variable}} — only render if truthy
}

// Generate PDF from template + data
export async function generatePDF(
  templateId: string,
  data: Record<string, any>
): Promise<Buffer> {
  const { rows: [tmplRow] } = await pool.query(
    "SELECT * FROM pdf_templates WHERE id = $1", [templateId]
  );
  if (!tmplRow) throw new Error("Template not found");

  const template: PDFTemplate = {
    ...tmplRow,
    layout: JSON.parse(tmplRow.layout),
    header: tmplRow.header ? JSON.parse(tmplRow.header) : null,
    footer: tmplRow.footer ? JSON.parse(tmplRow.footer) : null,
    sections: JSON.parse(tmplRow.sections),
    styles: JSON.parse(tmplRow.styles),
    variables: JSON.parse(tmplRow.variables),
  };

  // Validate required variables
  for (const v of template.variables) {
    if (v.required && data[v.name] === undefined) {
      throw new Error(`Missing required variable: ${v.name}`);
    }
    if (data[v.name] === undefined && v.default !== undefined) {
      data[v.name] = v.default;
    }
  }

  // Add built-in variables
  data._date = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
  data._year = new Date().getFullYear();
  data._page = 1;

  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({
      size: template.layout.pageSize,
      layout: template.layout.orientation,
      margins: template.layout.margins,
      bufferPages: true, // needed for headers/footers
    });

    const chunks: Buffer[] = [];
    doc.on("data", (chunk: Buffer) => chunks.push(chunk));
    doc.on("end", () => resolve(Buffer.concat(chunks)));
    doc.on("error", reject);

    // Set default font
    doc.font("Helvetica").fontSize(template.styles.fontSize);

    // Render sections
    for (const section of template.sections) {
      if (section.condition && !evaluateCondition(section.condition, data)) continue;
      renderSection(doc, section, data, template);
    }

    // Add headers/footers to all pages
    const pageCount = doc.bufferedPageRange().count;
    for (let i = 0; i < pageCount; i++) {
      doc.switchToPage(i);

      if (template.header) {
        const savedY = doc.y;
        doc.y = template.layout.margins.top / 2;
        renderHeaderFooter(doc, template.header, { ...data, _page: i + 1, _pages: pageCount }, template);
        doc.y = savedY;
      }

      if (template.footer) {
        doc.y = doc.page.height - template.layout.margins.bottom + 10;
        renderHeaderFooter(doc, template.footer, { ...data, _page: i + 1, _pages: pageCount }, template);
      }
    }

    doc.end();
  });
}

function renderSection(doc: typeof PDFDocument.prototype, section: TemplateSection, data: Record<string, any>, template: PDFTemplate): void {
  const style = section.style || {};

  if (style.marginTop) doc.moveDown(style.marginTop / template.styles.fontSize);

  switch (section.type) {
    case "text": {
      const text = interpolate(section.content || "", data);
      doc.fontSize(style.fontSize || template.styles.fontSize);
      if (style.bold) doc.font("Helvetica-Bold");
      else if (style.italic) doc.font("Helvetica-Oblique");
      else doc.font("Helvetica");
      if (style.color) doc.fillColor(style.color);
      else doc.fillColor(template.styles.primaryColor || "#000000");

      doc.text(text, { align: style.alignment || "left" });
      break;
    }

    case "table": {
      if (!section.table) break;
      const rows = data[section.table.dataKey] || [];
      renderTable(doc, section.table, rows, data, template);
      break;
    }

    case "spacer":
      doc.moveDown(2);
      break;

    case "divider":
      const x = doc.x;
      const width = doc.page.width - template.layout.margins.left - template.layout.margins.right;
      doc.moveTo(x, doc.y).lineTo(x + width, doc.y)
        .strokeColor("#CCCCCC").lineWidth(0.5).stroke();
      doc.moveDown(0.5);
      break;

    case "signature": {
      doc.moveDown(3);
      const sigX = doc.x;
      doc.moveTo(sigX, doc.y).lineTo(sigX + 200, doc.y)
        .strokeColor("#000000").lineWidth(1).stroke();
      doc.moveDown(0.3);
      doc.fontSize(10).text(interpolate(section.content || "Signature", data));
      doc.text(data._date);
      break;
    }

    case "pagebreak":
      doc.addPage();
      break;
  }

  if (style.marginBottom) doc.moveDown(style.marginBottom / template.styles.fontSize);
}

function renderTable(doc: any, table: NonNullable<TemplateSection["table"]>, rows: any[], data: Record<string, any>, template: PDFTemplate): void {
  const pageWidth = doc.page.width - template.layout.margins.left - template.layout.margins.right;
  const colWidths = table.widths.map((w) => (pageWidth * w) / 100);
  const startX = template.layout.margins.left;

  // Headers
  doc.font("Helvetica-Bold").fontSize(10);
  let x = startX;
  for (let i = 0; i < table.headers.length; i++) {
    doc.text(table.headers[i], x, doc.y, { width: colWidths[i], continued: i < table.headers.length - 1 });
    x += colWidths[i];
  }
  doc.moveDown(0.5);

  // Divider
  doc.moveTo(startX, doc.y).lineTo(startX + pageWidth, doc.y).strokeColor("#000").lineWidth(0.5).stroke();
  doc.moveDown(0.3);

  // Rows
  doc.font("Helvetica").fontSize(10);
  for (let r = 0; r < rows.length; r++) {
    const row = rows[r];

    // Striped background
    if (table.striped && r % 2 === 1) {
      doc.rect(startX, doc.y - 2, pageWidth, 16).fill("#F8F9FA");
      doc.fillColor("#000000");
    }

    x = startX;
    for (let i = 0; i < table.rowTemplate.length; i++) {
      const cellText = interpolate(table.rowTemplate[i], { ...data, item: row });
      doc.text(cellText, x, doc.y, { width: colWidths[i] });
      x += colWidths[i];
    }

    // Check if we need a new page
    if (doc.y > doc.page.height - template.layout.margins.bottom - 50) {
      doc.addPage();
    }
  }

  // Totals row
  if (table.totalRow) {
    doc.moveDown(0.3);
    doc.moveTo(startX, doc.y).lineTo(startX + pageWidth, doc.y).strokeColor("#000").stroke();
    doc.moveDown(0.3);
    doc.font("Helvetica-Bold");

    x = startX;
    for (let i = 0; i < table.totalRow.length; i++) {
      const cellText = interpolate(table.totalRow[i], data);
      doc.text(cellText, x, doc.y, { width: colWidths[i] });
      x += colWidths[i];
    }
  }

  doc.moveDown(1);
}

function renderHeaderFooter(doc: any, section: TemplateSection, data: Record<string, any>, template: PDFTemplate): void {
  const text = interpolate(section.content || "", data);
  doc.fontSize(8).fillColor("#999999").font("Helvetica");
  doc.text(text, template.layout.margins.left, doc.y, {
    align: section.style?.alignment || "center",
    width: doc.page.width - template.layout.margins.left - template.layout.margins.right,
  });
}

// Replace {{variable}} placeholders
function interpolate(template: string, data: Record<string, any>): string {
  return template.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
    const parts = key.trim().split(".");
    let value: any = data;
    for (const part of parts) {
      value = value?.[part];
    }
    if (value === undefined || value === null) return "";
    if (typeof value === "number" && key.includes("amount")) {
      return (value / 100).toFixed(2);
    }
    return String(value);
  });
}

function evaluateCondition(condition: string, data: Record<string, any>): boolean {
  const match = condition.match(/\{\{#if\s+(\w+)\}\}/);
  if (match) return !!data[match[1]];
  return true;
}

// Batch generate PDFs
export async function batchGenerate(
  templateId: string,
  dataList: Array<Record<string, any>>
): Promise<Array<{ data: Record<string, any>; buffer: Buffer }>> {
  const results = [];
  for (const data of dataList) {
    const buffer = await generatePDF(templateId, data);
    results.push({ data, buffer });
  }
  return results;
}

Results

  • PDF generation: 8s → 200ms — PDFKit generates directly without headless browser; 500MB RAM → 50MB; server handles 10x more concurrent generations
  • Template typos eliminated — {{client.name}} pulled from database; no more copy-paste errors; error rate 5% → 0%
  • Company address updated in 1 place — change template variable, all future documents use it; 15 Word templates replaced by 3 programmatic templates
  • Batch invoicing — 2,000 contracts generated in 7 minutes (was 2 days of manual work); sent via email automatically
  • Non-developers create templates — JSON template format with sections, tables, and variables; legal team defines templates without developer help

Related use cases