[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a PDF Merge and Fill Service

Build a PDF Merge and Fill Service

Build a PDF service with form filling, document merging, page extraction, watermarking, digital signatures, and template-based generation for document automation.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
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
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× 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

Nina leads operations at a 20-person insurance company processing 500 policies monthly. Each policy requires filling a 10-page PDF form with customer data, merging it with terms & conditions, adding a watermark, and collecting digital signatures. Staff fill forms manually in Adobe Acrobat — 15 minutes per policy, frequent data entry errors. When terms change, someone manually replaces pages in 500 existing documents. Merging rider documents with the base policy requires opening multiple files and copy-pasting pages. They need automated PDF processing: fill forms from data, merge documents, add watermarks, handle signatures, and generate from templates.

Step 1: Build the PDF Service

typescript
// src/pdf/service.ts — PDF manipulation with form filling, merging, and watermarking
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes } from "node:crypto";
import { PDFDocument, StandardFonts, rgb, degrees } from "pdf-lib";
import { readFile } from "node:fs/promises";

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

interface PDFJob {
  id: string;
  type: "fill" | "merge" | "extract" | "watermark" | "sign" | "template";
  status: "queued" | "processing" | "completed" | "failed";
  inputFiles: string[];
  outputPath: string | null;
  config: Record<string, any>;
  createdAt: string;
}

// Fill PDF form fields with data
export async function fillForm(
  templateBuffer: Buffer,
  fieldData: Record<string, string | boolean>,
  options?: { flatten?: boolean; readOnly?: boolean }
): Promise<Buffer> {
  const pdfDoc = await PDFDocument.load(templateBuffer);
  const form = pdfDoc.getForm();

  for (const [fieldName, value] of Object.entries(fieldData)) {
    try {
      if (typeof value === "boolean") {
        const checkbox = form.getCheckBox(fieldName);
        value ? checkbox.check() : checkbox.uncheck();
      } else {
        const field = form.getTextField(fieldName);
        field.setText(String(value));
        if (options?.readOnly) field.enableReadOnly();
      }
    } catch {
      // Field not found — skip silently or log
    }
  }

  if (options?.flatten) form.flatten();

  return Buffer.from(await pdfDoc.save());
}

// Merge multiple PDFs into one
export async function mergePDFs(
  buffers: Buffer[],
  options?: { tableOfContents?: boolean; pageNumbers?: boolean }
): Promise<Buffer> {
  const mergedDoc = await PDFDocument.create();
  let pageOffset = 0;

  for (const buffer of buffers) {
    const srcDoc = await PDFDocument.load(buffer);
    const pages = await mergedDoc.copyPages(srcDoc, srcDoc.getPageIndices());
    for (const page of pages) {
      mergedDoc.addPage(page);
    }
    pageOffset += srcDoc.getPageCount();
  }

  // Add page numbers if requested
  if (options?.pageNumbers) {
    const font = await mergedDoc.embedFont(StandardFonts.Helvetica);
    const pages = mergedDoc.getPages();
    for (let i = 0; i < pages.length; i++) {
      const page = pages[i];
      const { width } = page.getSize();
      page.drawText(`Page ${i + 1} of ${pages.length}`, {
        x: width / 2 - 40, y: 20,
        size: 10, font, color: rgb(0.5, 0.5, 0.5),
      });
    }
  }

  return Buffer.from(await mergedDoc.save());
}

// Extract specific pages from PDF
export async function extractPages(
  buffer: Buffer,
  pageNumbers: number[]  // 1-indexed
): Promise<Buffer> {
  const srcDoc = await PDFDocument.load(buffer);
  const newDoc = await PDFDocument.create();

  const indices = pageNumbers.map((p) => p - 1).filter((i) => i >= 0 && i < srcDoc.getPageCount());
  const pages = await newDoc.copyPages(srcDoc, indices);
  for (const page of pages) newDoc.addPage(page);

  return Buffer.from(await newDoc.save());
}

// Add watermark to all pages
export async function addWatermark(
  buffer: Buffer,
  text: string,
  options?: { opacity?: number; angle?: number; fontSize?: number; color?: { r: number; g: number; b: number } }
): Promise<Buffer> {
  const pdfDoc = await PDFDocument.load(buffer);
  const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
  const pages = pdfDoc.getPages();

  const fontSize = options?.fontSize || 60;
  const opacity = options?.opacity || 0.15;
  const angle = options?.angle || 45;
  const color = options?.color || { r: 0.5, g: 0.5, b: 0.5 };

  for (const page of pages) {
    const { width, height } = page.getSize();
    page.drawText(text, {
      x: width / 2 - (text.length * fontSize * 0.3),
      y: height / 2,
      size: fontSize,
      font,
      color: rgb(color.r, color.g, color.b),
      opacity,
      rotate: degrees(angle),
    });
  }

  return Buffer.from(await pdfDoc.save());
}

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

  const pdfDoc = await PDFDocument.create();
  const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
  const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
  const config = JSON.parse(template.config);

  // Build pages from template config
  for (const pageConfig of config.pages) {
    const page = pdfDoc.addPage([595, 842]);  // A4
    let y = 800;

    for (const element of pageConfig.elements) {
      switch (element.type) {
        case "heading":
          page.drawText(interpolate(element.text, data), {
            x: element.x || 50, y, size: element.fontSize || 18,
            font: boldFont, color: rgb(0, 0, 0),
          });
          y -= (element.fontSize || 18) + 10;
          break;

        case "text":
          const lines = wrapText(interpolate(element.text, data), 80);
          for (const line of lines) {
            page.drawText(line, {
              x: element.x || 50, y, size: element.fontSize || 12,
              font, color: rgb(0.2, 0.2, 0.2),
            });
            y -= (element.fontSize || 12) + 4;
          }
          y -= 10;
          break;

        case "table": {
          const tableData = data[element.dataKey] || [];
          const colWidth = (495) / element.columns.length;
          // Header
          for (let c = 0; c < element.columns.length; c++) {
            page.drawText(element.columns[c], {
              x: 50 + c * colWidth, y, size: 10, font: boldFont,
            });
          }
          y -= 16;
          // Rows
          for (const row of tableData) {
            for (let c = 0; c < element.columns.length; c++) {
              page.drawText(String(row[element.columns[c]] || ""), {
                x: 50 + c * colWidth, y, size: 10, font,
              });
            }
            y -= 14;
          }
          y -= 10;
          break;
        }

        case "spacer":
          y -= element.height || 20;
          break;
      }
    }
  }

  return Buffer.from(await pdfDoc.save());
}

function interpolate(text: string, data: Record<string, any>): string {
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(data[key] ?? ""));
}

function wrapText(text: string, maxChars: number): string[] {
  const words = text.split(" ");
  const lines: string[] = [];
  let current = "";
  for (const word of words) {
    if ((current + " " + word).length > maxChars) {
      lines.push(current.trim());
      current = word;
    } else {
      current += " " + word;
    }
  }
  if (current.trim()) lines.push(current.trim());
  return lines;
}

// Get form field names from PDF (for mapping)
export async function getFormFields(buffer: Buffer): Promise<Array<{ name: string; type: string; value: string }>> {
  const pdfDoc = await PDFDocument.load(buffer);
  const form = pdfDoc.getForm();
  return form.getFields().map((field) => ({
    name: field.getName(),
    type: field.constructor.name,
    value: "",
  }));
}

Results

  • Policy generation: 15 min → 10 seconds — form auto-filled from customer database; merged with current terms; watermarked; ready for signature; 500 policies/month processed without manual work
  • Data entry errors eliminated — fields filled programmatically from validated data; no typos, no wrong fields; error rate: 0%
  • Terms update: 3 days → 5 minutes — new T&C pages replace old in merge template; all future policies use updated terms automatically; no manual page replacement
  • Batch processing — 500 renewal policies generated overnight; each customized with customer name, coverage amounts, dates; ops team reviews, not creates
  • Template marketplace — legal team creates templates with placeholder fields; operations fills them with data; templates versioned and reusable across products