skills/pdf-generator/scripts/generate-from-template.ts

508 lines
14 KiB
TypeScript

#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-from-template.ts - Generate PDF from existing templates
*
* Modifies existing PDF templates by filling form fields, adding content
* overlays, and merging documents. Preserves original formatting.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-from-template.ts <template.pdf> <spec.json> <output.pdf>
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
*
* Permissions:
* --allow-read: Read template, specification files, and images
* --allow-write: Write output PDF file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename, dirname, resolve } from "jsr:@std/path@1.0.8";
import {
PDFDocument,
StandardFonts,
rgb,
degrees,
// deno-lint-ignore no-explicit-any
} from "npm:pdf-lib@1.17.1" as any;
// === Types ===
export interface FormFieldValue {
/** Field name as it appears in the PDF form */
name: string;
/** Value to set */
value: string | boolean;
}
export interface TextOverlay {
type: "text";
/** Page number (1-indexed) */
page: number;
x: number;
y: number;
text: string;
fontSize?: number;
font?: "Helvetica" | "HelveticaBold" | "TimesRoman" | "Courier";
color?: { r: number; g: number; b: number };
rotate?: number;
}
export interface ImageOverlay {
type: "image";
/** Page number (1-indexed) */
page: number;
x: number;
y: number;
path: string;
width?: number;
height?: number;
opacity?: number;
}
export interface RectangleOverlay {
type: "rectangle";
page: number;
x: number;
y: number;
width: number;
height: number;
color?: { r: number; g: number; b: number };
opacity?: number;
}
export type Overlay = TextOverlay | ImageOverlay | RectangleOverlay;
export interface MergeSource {
/** Path to PDF file to merge */
path: string;
/** Which pages to include (1-indexed, all if omitted) */
pages?: number[];
}
export interface TemplateSpec {
/** Form field values to fill */
formFields?: FormFieldValue[];
/** Content overlays to add on pages */
overlays?: Overlay[];
/** PDFs to append after the template */
appendPdfs?: MergeSource[];
/** PDFs to prepend before the template */
prependPdfs?: MergeSource[];
/** Flatten form fields (make them non-editable) */
flattenForm?: boolean;
/** Pages to include from template (1-indexed, all if omitted) */
includePages?: number[];
/** Pages to exclude from template (1-indexed) */
excludePages?: number[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-from-template";
const FONT_MAP: Record<string, typeof StandardFonts[keyof typeof StandardFonts]> = {
"Helvetica": StandardFonts.Helvetica,
"HelveticaBold": StandardFonts.HelveticaBold,
"TimesRoman": StandardFonts.TimesRoman,
"Courier": StandardFonts.Courier,
};
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Generate PDF from existing templates
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <template.pdf> <spec.json> <output.pdf>
Arguments:
<template.pdf> Path to the template PDF file
<spec.json> Path to JSON specification for modifications
<output.pdf> Path for output PDF file
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Specification Format:
{
"formFields": [
{ "name": "FullName", "value": "John Smith" },
{ "name": "Email", "value": "john@example.com" },
{ "name": "Agree", "value": true }
],
"overlays": [
{
"type": "text",
"page": 1,
"x": 400,
"y": 50,
"text": "APPROVED",
"fontSize": 24,
"color": { "r": 0, "g": 0.5, "b": 0 },
"rotate": 45
}
],
"flattenForm": true
}
Features:
- Fill form fields (text, checkbox, radio, dropdown)
- Add text/image overlays on any page
- Merge multiple PDFs
- Include/exclude specific pages
- Flatten forms to prevent editing
Examples:
# Fill form and save
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
form-template.pdf form-data.json filled-form.pdf
# Add watermark overlay
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
document.pdf watermark-spec.json watermarked.pdf -v
`);
}
// === Utility Functions ===
function getColor(color?: { r: number; g: number; b: number }): ReturnType<typeof rgb> {
if (!color) return rgb(0, 0, 0);
return rgb(color.r, color.g, color.b);
}
// === Core Logic ===
// deno-lint-ignore no-explicit-any
async function loadAndCopyPages(pdfDoc: any, source: MergeSource, specDir: string): Promise<any[]> {
const sourcePath = resolve(specDir, source.path);
const sourceData = await Deno.readFile(sourcePath);
const sourcePdf = await PDFDocument.load(sourceData);
const pageIndices = source.pages
? source.pages.map(p => p - 1) // Convert to 0-indexed
: sourcePdf.getPageIndices();
return await pdfDoc.copyPages(sourcePdf, pageIndices);
}
export async function generateFromTemplate(
templatePath: string,
spec: TemplateSpec,
outputPath: string,
options: { verbose?: boolean; specDir?: string } = {}
): Promise<void> {
const { verbose = false, specDir = "." } = options;
// Load template
const templateData = await Deno.readFile(templatePath);
const templatePdf = await PDFDocument.load(templateData);
if (verbose) {
console.error(`Loaded template: ${basename(templatePath)}`);
console.error(`Template pages: ${templatePdf.getPageCount()}`);
}
// Create output document
const pdfDoc = await PDFDocument.create();
// Copy metadata from template
pdfDoc.setTitle(templatePdf.getTitle() || "");
pdfDoc.setAuthor(templatePdf.getAuthor() || "");
pdfDoc.setSubject(templatePdf.getSubject() || "");
// Embed fonts for overlays
// deno-lint-ignore no-explicit-any
const fonts = new Map<string, any>();
for (const [name, fontEnum] of Object.entries(FONT_MAP)) {
fonts.set(name, await pdfDoc.embedFont(fontEnum));
}
// Add prepended PDFs
if (spec.prependPdfs && spec.prependPdfs.length > 0) {
for (const source of spec.prependPdfs) {
const pages = await loadAndCopyPages(pdfDoc, source, specDir);
for (const page of pages) {
pdfDoc.addPage(page);
}
if (verbose) {
console.error(`Prepended ${pages.length} pages from ${source.path}`);
}
}
}
// Determine which template pages to include
const templatePageCount = templatePdf.getPageCount();
let templatePageIndices: number[] = [];
if (spec.includePages && spec.includePages.length > 0) {
templatePageIndices = spec.includePages.map(p => p - 1);
} else if (spec.excludePages && spec.excludePages.length > 0) {
const excludeSet = new Set(spec.excludePages.map(p => p - 1));
for (let i = 0; i < templatePageCount; i++) {
if (!excludeSet.has(i)) {
templatePageIndices.push(i);
}
}
} else {
templatePageIndices = templatePdf.getPageIndices();
}
// Copy template pages
const templatePages = await pdfDoc.copyPages(templatePdf, templatePageIndices);
// deno-lint-ignore no-explicit-any
const addedPages: any[] = [];
for (const page of templatePages) {
pdfDoc.addPage(page);
addedPages.push(page);
}
if (verbose) {
console.error(`Added ${addedPages.length} template pages`);
}
// Fill form fields
if (spec.formFields && spec.formFields.length > 0) {
try {
const form = pdfDoc.getForm();
let filledCount = 0;
for (const fieldSpec of spec.formFields) {
try {
const field = form.getField(fieldSpec.name);
const fieldType = field.constructor.name;
if (typeof fieldSpec.value === "boolean") {
// Checkbox
if (fieldType.includes("CheckBox")) {
if (fieldSpec.value) {
// deno-lint-ignore no-explicit-any
(field as any).check();
} else {
// deno-lint-ignore no-explicit-any
(field as any).uncheck();
}
filledCount++;
}
} else {
// Text field, dropdown, etc.
if (fieldType.includes("Text")) {
// deno-lint-ignore no-explicit-any
(field as any).setText(fieldSpec.value);
filledCount++;
} else if (fieldType.includes("Dropdown") || fieldType.includes("OptionList")) {
// deno-lint-ignore no-explicit-any
(field as any).select(fieldSpec.value);
filledCount++;
} else if (fieldType.includes("Radio")) {
// deno-lint-ignore no-explicit-any
(field as any).select(fieldSpec.value);
filledCount++;
}
}
} catch (error) {
if (verbose) {
console.error(`Failed to fill field "${fieldSpec.name}": ${error}`);
}
}
}
if (verbose) {
console.error(`Filled ${filledCount} form fields`);
}
// Flatten form if requested
if (spec.flattenForm) {
form.flatten();
if (verbose) {
console.error("Form flattened");
}
}
} catch (error) {
if (verbose) {
console.error(`Form processing failed: ${error}`);
}
}
}
// Apply overlays
if (spec.overlays && spec.overlays.length > 0) {
// Calculate page offset from prepended PDFs
const prependedPageCount = spec.prependPdfs?.reduce((sum, s) => {
// This is approximate - we'd need to track actual pages added
return sum + (s.pages?.length || 0);
}, 0) || 0;
for (const overlay of spec.overlays) {
// Adjust page index for prepended pages and 1-indexing
const pageIndex = (overlay.page - 1) + prependedPageCount;
if (pageIndex < 0 || pageIndex >= pdfDoc.getPageCount()) {
if (verbose) {
console.error(`Overlay page ${overlay.page} out of range`);
}
continue;
}
const page = pdfDoc.getPage(pageIndex);
try {
switch (overlay.type) {
case "text": {
const fontName = overlay.font || "Helvetica";
const font = fonts.get(fontName);
// deno-lint-ignore no-explicit-any
const textOptions: any = {
x: overlay.x,
y: overlay.y,
size: overlay.fontSize || 12,
font,
color: getColor(overlay.color),
};
if (overlay.rotate) {
textOptions.rotate = degrees(overlay.rotate);
}
page.drawText(overlay.text, textOptions);
break;
}
case "image": {
const imagePath = resolve(specDir, overlay.path);
const imageData = await Deno.readFile(imagePath);
const ext = overlay.path.toLowerCase().split(".").pop();
// deno-lint-ignore no-explicit-any
let image: any;
if (ext === "png") {
image = await pdfDoc.embedPng(imageData);
} else if (ext === "jpg" || ext === "jpeg") {
image = await pdfDoc.embedJpg(imageData);
} else {
throw new Error(`Unsupported image format: ${ext}`);
}
const dims = image.scale(1);
page.drawImage(image, {
x: overlay.x,
y: overlay.y,
width: overlay.width || dims.width,
height: overlay.height || dims.height,
opacity: overlay.opacity,
});
break;
}
case "rectangle": {
page.drawRectangle({
x: overlay.x,
y: overlay.y,
width: overlay.width,
height: overlay.height,
color: getColor(overlay.color),
opacity: overlay.opacity,
});
break;
}
}
} catch (error) {
if (verbose) {
console.error(`Failed to apply overlay: ${error}`);
}
}
}
if (verbose) {
console.error(`Applied ${spec.overlays.length} overlays`);
}
}
// Add appended PDFs
if (spec.appendPdfs && spec.appendPdfs.length > 0) {
for (const source of spec.appendPdfs) {
const pages = await loadAndCopyPages(pdfDoc, source, specDir);
for (const page of pages) {
pdfDoc.addPage(page);
}
if (verbose) {
console.error(`Appended ${pages.length} pages from ${source.path}`);
}
}
}
// Write output
const pdfBytes = await pdfDoc.save();
await Deno.writeFile(outputPath, pdfBytes);
if (verbose) {
console.error(`Wrote ${outputPath} (${pdfDoc.getPageCount()} pages)`);
}
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 3) {
console.error(
"Error: template.pdf, spec.json, and output.pdf are required\n"
);
printHelp();
Deno.exit(1);
}
const templatePath = positionalArgs[0];
const specPath = positionalArgs[1];
const outputPath = positionalArgs[2];
try {
// Read specification
const specText = await Deno.readTextFile(specPath);
const spec = JSON.parse(specText) as TemplateSpec;
const specDir = dirname(resolve(specPath));
await generateFromTemplate(templatePath, spec, outputPath, {
verbose: parsed.verbose,
specDir,
});
console.log(`Created: ${outputPath}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}