ca-grow-ops-manager/backend/src/services/pdf.service.ts
fullsizemalt 4bdbfc82ca Add tinypdf-plus integration with PDF generation and branding
Backend changes:
- Add tinypdf-plus dependency for TTF/OTF font support
- Create PDF service with text, certificate, and label generation
- Add PDF API endpoints (/api/pdf/*)
- Add branding service for custom fonts and styling
- Add branding API endpoints (/api/branding/*)
- Add font registration endpoint for custom fonts

Frontend changes:
- Add PDF library with download utilities
- Add PDFDownloadButton component for reusable PDF downloads
- Add branding API client for managing branding configs
- Update ReportsPage with PDF generation tab

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:07:35 -08:00

283 lines
6.8 KiB
TypeScript

/**
* PDF Generation Service
* Uses tinypdf-plus for server-side PDF generation with custom font support
*/
// @ts-ignore - tinypdf-plus doesn't have types yet
import { pdf, loadFont } from 'tinypdf-plus'
import { randomUUID } from 'crypto'
export interface PDFOptions {
title?: string
author?: string
subject?: string
keywords?: string
creator?: string
}
export interface TextOptions {
font?: string
color?: string
size?: number
align?: 'left' | 'center' | 'right'
width?: number
}
export interface PDFDocumentConfig {
width: number
height: number
margin: number
}
export interface FontConfig {
name: string
data: Uint8Array
}
class PDFService {
private registeredFonts = new Map<string, FontConfig>()
/**
* Register a custom font for PDF generation
*/
registerFont(name: string, fontData: Uint8Array): void {
try {
const font = loadFont(fontData, name)
this.registeredFonts.set(name, { name, data: fontData })
} catch (error) {
console.error(`Failed to register font ${name}:`, error)
throw new Error(`Font registration failed: ${error}`)
}
}
/**
* Get list of registered fonts
*/
getRegisteredFonts(): string[] {
return Array.from(this.registeredFonts.keys())
}
/**
* Check if a font is registered
*/
hasFont(name: string): boolean {
return this.registeredFonts.has(name)
}
/**
* Generate a simple text PDF
*/
generateTextPDF(
content: string,
options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
const textWidth = width - margin * 2
let y = height - margin
const lineHeight = 14
// Simple text wrapping
const lines = this.wrapText(content, textWidth, 12)
for (const line of lines) {
ctx.text(line, margin, y, 12)
y -= lineHeight
if (y < margin) break
}
})
return doc.build()
}
/**
* Generate a certificate PDF
*/
generateCertificate(
data: {
title: string
recipientName: string
description: string
date: string
certificateNumber: string
authorizedBy?: string
},
options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
const centerX = width / 2
let y = height - margin
// Title
ctx.text(data.title, centerX, y, 24, {
font: this.registeredFonts.has('Title') ? 'Title' : undefined,
align: 'center',
width: width - margin * 2
})
y -= 50
// Certificate number
ctx.text(`Certificate No: ${data.certificateNumber}`, centerX, y, 10, {
color: '#666',
align: 'center',
width: width - margin * 2
})
y -= 40
// Separator line
ctx.line(margin, y, width - margin, y, '#ccc', 1)
y -= 60
// Recipient name
ctx.text(data.recipientName, centerX, y, 18, {
font: this.registeredFonts.has('Body') ? 'Body' : undefined,
align: 'center',
width: width - margin * 2
})
y -= 30
// Description
const descriptionLines = this.wrapText(data.description, width - margin * 3, 12)
for (const line of descriptionLines) {
ctx.text(line, centerX, y, 12, {
align: 'center',
width: width - margin * 2
})
y -= 18
if (y < margin + 100) break
}
y -= 40
// Date
ctx.text(`Date: ${data.date}`, centerX, y, 12, {
color: '#666',
align: 'center',
width: width - margin * 2
})
// Authorized by (if provided)
if (data.authorizedBy) {
ctx.text(`Authorized by: ${data.authorizedBy}`, centerX, margin + 30, 10, {
color: '#666',
align: 'center',
width: width - margin * 2
})
}
})
return doc.build()
}
/**
* Generate a label PDF (for plants, batches, etc.)
*/
generateLabel(
data: {
title: string
subtitle?: string
qrCode?: string
details: { label: string; value: string }[]
},
options: PDFDocumentConfig = { width: 288, height: 144, margin: 18 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
let y = height - margin
// Title
ctx.text(data.title, margin, y, 14, {
font: this.registeredFonts.has('Label') ? 'Label' : undefined,
color: '#333'
})
y -= 20
// Subtitle (if provided)
if (data.subtitle) {
ctx.text(data.subtitle, margin, y, 10, { color: '#666' })
y -= 15
}
y -= 10
// Details
for (const detail of data.details) {
ctx.text(`${detail.label}: ${detail.value}`, margin, y, 9)
y -= 12
if (y < margin) break
}
// QR code placeholder (if provided)
if (data.qrCode) {
ctx.text(`QR: ${data.qrCode}`, margin, margin + 20, 8, { color: '#999' })
}
})
return doc.build()
}
/**
* Helper function to wrap text
*/
private wrapText(text: string, maxWidth: number, fontSize: number): string[] {
const lines: string[] = []
const words = text.split(' ')
let currentLine = ''
// Approximate character width (default Helvetica)
const avgCharWidth = fontSize * 0.5
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word
const testWidth = testLine.length * avgCharWidth
if (testWidth < maxWidth) {
currentLine = testLine
} else {
if (currentLine) lines.push(currentLine)
currentLine = word
}
}
if (currentLine) lines.push(currentLine)
return lines.length ? lines : ['']
}
}
export const pdfService = new PDFService()