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>
283 lines
6.8 KiB
TypeScript
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()
|