/** * 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() /** * 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()