2026-02-14 15:26:42 +01:00
|
|
|
import { jsPDF } from 'jspdf'
|
|
|
|
|
import { autoTable } from 'jspdf-autotable'
|
|
|
|
|
import html2canvas from 'html2canvas'
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Brand constants
|
|
|
|
|
// =========================================================================
|
|
|
|
|
const COLORS = {
|
|
|
|
|
darkBlue: '#053d57',
|
|
|
|
|
red: '#de0f1e',
|
|
|
|
|
teal: '#557f8c',
|
|
|
|
|
lightGray: '#f0f4f8',
|
|
|
|
|
white: '#ffffff',
|
|
|
|
|
textDark: '#1a1a1a',
|
|
|
|
|
textMuted: '#888888',
|
|
|
|
|
} as const
|
|
|
|
|
|
|
|
|
|
const DARK_BLUE_RGB: [number, number, number] = [5, 61, 87]
|
|
|
|
|
const TEAL_RGB: [number, number, number] = [85, 127, 140]
|
|
|
|
|
const RED_RGB: [number, number, number] = [222, 15, 30]
|
|
|
|
|
const LIGHT_GRAY_RGB: [number, number, number] = [240, 244, 248]
|
|
|
|
|
|
|
|
|
|
const PAGE_WIDTH = 210 // A4 mm
|
|
|
|
|
const PAGE_HEIGHT = 297
|
|
|
|
|
const MARGIN = 15
|
|
|
|
|
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Font & logo caching
|
|
|
|
|
// =========================================================================
|
|
|
|
|
let cachedFonts: { regular: string; bold: string } | null = null
|
|
|
|
|
let cachedLogo: string | null = null
|
|
|
|
|
let fontLoadAttempted = false
|
|
|
|
|
let logoLoadAttempted = false
|
|
|
|
|
|
|
|
|
|
async function loadFonts(): Promise<{ regular: string; bold: string } | null> {
|
|
|
|
|
if (cachedFonts) return cachedFonts
|
|
|
|
|
if (fontLoadAttempted) return null
|
|
|
|
|
fontLoadAttempted = true
|
|
|
|
|
try {
|
|
|
|
|
const [regularRes, boldRes] = await Promise.all([
|
|
|
|
|
fetch('/fonts/Montserrat-Regular.ttf'),
|
|
|
|
|
fetch('/fonts/Montserrat-Bold.ttf'),
|
|
|
|
|
])
|
|
|
|
|
if (!regularRes.ok || !boldRes.ok) return null
|
|
|
|
|
const [regularBuf, boldBuf] = await Promise.all([
|
|
|
|
|
regularRes.arrayBuffer(),
|
|
|
|
|
boldRes.arrayBuffer(),
|
|
|
|
|
])
|
|
|
|
|
const toBase64 = (buf: ArrayBuffer) => {
|
|
|
|
|
const bytes = new Uint8Array(buf)
|
|
|
|
|
let binary = ''
|
|
|
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
|
|
|
binary += String.fromCharCode(bytes[i])
|
|
|
|
|
}
|
|
|
|
|
return btoa(binary)
|
|
|
|
|
}
|
|
|
|
|
cachedFonts = { regular: toBase64(regularBuf), bold: toBase64(boldBuf) }
|
|
|
|
|
return cachedFonts
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadLogo(): Promise<string | null> {
|
|
|
|
|
if (cachedLogo) return cachedLogo
|
|
|
|
|
if (logoLoadAttempted) return null
|
|
|
|
|
logoLoadAttempted = true
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/images/MOPC-blue-long.png')
|
|
|
|
|
if (!res.ok) return null
|
|
|
|
|
const blob = await res.blob()
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onloadend = () => {
|
|
|
|
|
cachedLogo = reader.result as string
|
|
|
|
|
resolve(cachedLogo)
|
|
|
|
|
}
|
|
|
|
|
reader.onerror = () => resolve(null)
|
|
|
|
|
reader.readAsDataURL(blob)
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Document creation
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export interface ReportDocumentOptions {
|
|
|
|
|
orientation?: 'portrait' | 'landscape'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createReportDocument(
|
|
|
|
|
options?: ReportDocumentOptions
|
|
|
|
|
): Promise<jsPDF> {
|
|
|
|
|
const doc = new jsPDF({
|
|
|
|
|
orientation: options?.orientation || 'portrait',
|
|
|
|
|
unit: 'mm',
|
|
|
|
|
format: 'a4',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Load and register fonts
|
|
|
|
|
const fonts = await loadFonts()
|
|
|
|
|
if (fonts) {
|
|
|
|
|
doc.addFileToVFS('Montserrat-Regular.ttf', fonts.regular)
|
|
|
|
|
doc.addFont('Montserrat-Regular.ttf', 'Montserrat', 'normal')
|
|
|
|
|
doc.addFileToVFS('Montserrat-Bold.ttf', fonts.bold)
|
|
|
|
|
doc.addFont('Montserrat-Bold.ttf', 'Montserrat', 'bold')
|
|
|
|
|
doc.setFont('Montserrat', 'normal')
|
|
|
|
|
} else {
|
|
|
|
|
doc.setFont('helvetica', 'normal')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return doc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Cover page
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export interface CoverPageOptions {
|
|
|
|
|
title: string
|
|
|
|
|
subtitle?: string
|
|
|
|
|
roundName?: string
|
|
|
|
|
programName?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addCoverPage(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
options: CoverPageOptions
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const logo = await loadLogo()
|
|
|
|
|
|
|
|
|
|
// Logo centered
|
|
|
|
|
if (logo) {
|
|
|
|
|
const logoWidth = 80
|
|
|
|
|
const logoHeight = 20
|
|
|
|
|
const logoX = (PAGE_WIDTH - logoWidth) / 2
|
|
|
|
|
doc.addImage(logo, 'PNG', logoX, 60, logoWidth, logoHeight)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
doc.setFont(fontName, 'bold')
|
|
|
|
|
doc.setFontSize(24)
|
|
|
|
|
doc.setTextColor(...DARK_BLUE_RGB)
|
|
|
|
|
doc.text(options.title, PAGE_WIDTH / 2, logo ? 110 : 100, { align: 'center' })
|
|
|
|
|
|
|
|
|
|
// Subtitle
|
|
|
|
|
if (options.subtitle) {
|
|
|
|
|
doc.setFont(fontName, 'normal')
|
|
|
|
|
doc.setFontSize(14)
|
|
|
|
|
doc.setTextColor(...TEAL_RGB)
|
|
|
|
|
doc.text(options.subtitle, PAGE_WIDTH / 2, logo ? 125 : 115, { align: 'center' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Round & program
|
|
|
|
|
let infoY = logo ? 145 : 135
|
|
|
|
|
doc.setFontSize(12)
|
|
|
|
|
doc.setTextColor(...DARK_BLUE_RGB)
|
|
|
|
|
|
|
|
|
|
if (options.programName) {
|
|
|
|
|
doc.text(options.programName, PAGE_WIDTH / 2, infoY, { align: 'center' })
|
|
|
|
|
infoY += 8
|
|
|
|
|
}
|
|
|
|
|
if (options.roundName) {
|
|
|
|
|
doc.setFont(fontName, 'bold')
|
|
|
|
|
doc.text(options.roundName, PAGE_WIDTH / 2, infoY, { align: 'center' })
|
|
|
|
|
infoY += 8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Date
|
|
|
|
|
doc.setFont(fontName, 'normal')
|
|
|
|
|
doc.setFontSize(10)
|
|
|
|
|
doc.setTextColor(136, 136, 136)
|
|
|
|
|
doc.text(
|
|
|
|
|
`Generated on ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`,
|
|
|
|
|
PAGE_WIDTH / 2,
|
|
|
|
|
infoY + 10,
|
|
|
|
|
{ align: 'center' }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Decorative line
|
|
|
|
|
doc.setDrawColor(...TEAL_RGB)
|
|
|
|
|
doc.setLineWidth(0.5)
|
|
|
|
|
doc.line(MARGIN + 30, infoY + 20, PAGE_WIDTH - MARGIN - 30, infoY + 20)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Header (on content pages)
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export async function addHeader(doc: jsPDF, title: string): Promise<void> {
|
|
|
|
|
const logo = await loadLogo()
|
|
|
|
|
|
|
|
|
|
if (logo) {
|
|
|
|
|
doc.addImage(logo, 'PNG', MARGIN, 8, 30, 8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
doc.setFont(fontName, 'bold')
|
|
|
|
|
doc.setFontSize(11)
|
|
|
|
|
doc.setTextColor(...DARK_BLUE_RGB)
|
|
|
|
|
doc.text(title, PAGE_WIDTH / 2, 14, { align: 'center' })
|
|
|
|
|
|
|
|
|
|
doc.setFont(fontName, 'normal')
|
|
|
|
|
doc.setFontSize(8)
|
|
|
|
|
doc.setTextColor(136, 136, 136)
|
|
|
|
|
doc.text(
|
|
|
|
|
new Date().toLocaleDateString('en-GB'),
|
|
|
|
|
PAGE_WIDTH - MARGIN,
|
|
|
|
|
14,
|
|
|
|
|
{ align: 'right' }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Line under header
|
|
|
|
|
doc.setDrawColor(...TEAL_RGB)
|
|
|
|
|
doc.setLineWidth(0.3)
|
|
|
|
|
doc.line(MARGIN, 18, PAGE_WIDTH - MARGIN, 18)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Footer
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function addFooter(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
pageNumber: number,
|
|
|
|
|
totalPages: number
|
|
|
|
|
): void {
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
const y = PAGE_HEIGHT - 10
|
|
|
|
|
|
|
|
|
|
doc.setFont(fontName, 'normal')
|
|
|
|
|
doc.setFontSize(7)
|
|
|
|
|
doc.setTextColor(136, 136, 136)
|
|
|
|
|
|
|
|
|
|
doc.text('Generated by MOPC Platform', MARGIN, y)
|
|
|
|
|
doc.text('Confidential', PAGE_WIDTH / 2, y, { align: 'center' })
|
|
|
|
|
doc.text(`Page ${pageNumber} of ${totalPages}`, PAGE_WIDTH - MARGIN, y, {
|
|
|
|
|
align: 'right',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addAllPageFooters(doc: jsPDF): void {
|
|
|
|
|
const totalPages = doc.getNumberOfPages()
|
|
|
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
|
|
|
doc.setPage(i)
|
|
|
|
|
addFooter(doc, i, totalPages)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Section title
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function addSectionTitle(doc: jsPDF, title: string, y: number): number {
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
|
|
|
|
|
doc.setFont(fontName, 'bold')
|
|
|
|
|
doc.setFontSize(16)
|
|
|
|
|
doc.setTextColor(...DARK_BLUE_RGB)
|
|
|
|
|
doc.text(title, MARGIN, y)
|
|
|
|
|
|
|
|
|
|
// Teal underline
|
|
|
|
|
doc.setDrawColor(...TEAL_RGB)
|
|
|
|
|
doc.setLineWidth(0.5)
|
|
|
|
|
doc.line(MARGIN, y + 2, MARGIN + doc.getTextWidth(title), y + 2)
|
|
|
|
|
|
|
|
|
|
return y + 12
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Stat cards row
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function addStatCards(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
stats: Array<{ label: string; value: string | number }>,
|
|
|
|
|
y: number
|
|
|
|
|
): number {
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
const cardCount = Math.min(stats.length, 4)
|
|
|
|
|
const gap = 4
|
|
|
|
|
const cardWidth = (CONTENT_WIDTH - gap * (cardCount - 1)) / cardCount
|
|
|
|
|
const cardHeight = 22
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < cardCount; i++) {
|
|
|
|
|
const x = MARGIN + i * (cardWidth + gap)
|
|
|
|
|
|
|
|
|
|
// Card background
|
|
|
|
|
doc.setFillColor(...LIGHT_GRAY_RGB)
|
|
|
|
|
doc.roundedRect(x, y, cardWidth, cardHeight, 2, 2, 'F')
|
|
|
|
|
|
|
|
|
|
// Value
|
|
|
|
|
doc.setFont(fontName, 'bold')
|
|
|
|
|
doc.setFontSize(18)
|
|
|
|
|
doc.setTextColor(...DARK_BLUE_RGB)
|
|
|
|
|
doc.text(String(stats[i].value), x + cardWidth / 2, y + 10, {
|
|
|
|
|
align: 'center',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Label
|
|
|
|
|
doc.setFont(fontName, 'normal')
|
|
|
|
|
doc.setFontSize(8)
|
|
|
|
|
doc.setTextColor(...TEAL_RGB)
|
|
|
|
|
doc.text(stats[i].label, x + cardWidth / 2, y + 18, { align: 'center' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return y + cardHeight + 8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Table via autoTable
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function addTable(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
headers: string[],
|
|
|
|
|
rows: (string | number)[][],
|
|
|
|
|
y: number
|
|
|
|
|
): number {
|
|
|
|
|
const fontName = getFont(doc)
|
|
|
|
|
|
|
|
|
|
autoTable(doc, {
|
|
|
|
|
startY: y,
|
|
|
|
|
head: [headers],
|
|
|
|
|
body: rows,
|
|
|
|
|
margin: { left: MARGIN, right: MARGIN },
|
|
|
|
|
styles: {
|
|
|
|
|
font: fontName,
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
cellPadding: 3,
|
|
|
|
|
textColor: [26, 26, 26],
|
|
|
|
|
},
|
|
|
|
|
headStyles: {
|
|
|
|
|
fillColor: DARK_BLUE_RGB,
|
|
|
|
|
textColor: [255, 255, 255],
|
|
|
|
|
fontStyle: 'bold',
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
},
|
|
|
|
|
alternateRowStyles: {
|
|
|
|
|
fillColor: [248, 248, 248],
|
|
|
|
|
},
|
|
|
|
|
theme: 'grid',
|
|
|
|
|
tableLineColor: [220, 220, 220],
|
|
|
|
|
tableLineWidth: 0.1,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const finalY = (doc as any).lastAutoTable?.finalY ?? y + 20
|
|
|
|
|
return finalY + 8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Chart image capture
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export async function addChartImage(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
element: HTMLElement,
|
|
|
|
|
y: number,
|
|
|
|
|
options?: { maxHeight?: number }
|
|
|
|
|
): Promise<number> {
|
|
|
|
|
const canvas = await html2canvas(element, {
|
|
|
|
|
scale: 2,
|
|
|
|
|
useCORS: true,
|
|
|
|
|
backgroundColor: COLORS.white,
|
|
|
|
|
logging: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const imgData = canvas.toDataURL('image/jpeg', 0.95)
|
|
|
|
|
const imgWidth = CONTENT_WIDTH
|
|
|
|
|
const ratio = canvas.height / canvas.width
|
|
|
|
|
let imgHeight = imgWidth * ratio
|
|
|
|
|
const maxH = options?.maxHeight || 100
|
|
|
|
|
|
|
|
|
|
if (imgHeight > maxH) {
|
|
|
|
|
imgHeight = maxH
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check page break
|
|
|
|
|
y = checkPageBreak(doc, y, imgHeight + 5)
|
|
|
|
|
|
|
|
|
|
doc.addImage(imgData, 'JPEG', MARGIN, y, imgWidth, imgHeight)
|
|
|
|
|
return y + imgHeight + 8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Page break helper
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function checkPageBreak(
|
|
|
|
|
doc: jsPDF,
|
|
|
|
|
y: number,
|
|
|
|
|
neededHeight: number
|
|
|
|
|
): number {
|
|
|
|
|
const availableHeight = PAGE_HEIGHT - 20 // leave room for footer
|
|
|
|
|
if (y + neededHeight > availableHeight) {
|
|
|
|
|
doc.addPage()
|
|
|
|
|
return 25 // start below header area
|
|
|
|
|
}
|
|
|
|
|
return y
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addPageBreak(doc: jsPDF): void {
|
|
|
|
|
doc.addPage()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Save
|
|
|
|
|
// =========================================================================
|
|
|
|
|
export function savePdf(doc: jsPDF, filename: string): void {
|
|
|
|
|
doc.save(filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Helper
|
|
|
|
|
// =========================================================================
|
|
|
|
|
function getFont(doc: jsPDF): string {
|
|
|
|
|
// Check if Montserrat was loaded
|
|
|
|
|
try {
|
|
|
|
|
const fonts = doc.getFontList()
|
|
|
|
|
if (fonts['Montserrat']) return 'Montserrat'
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback
|
|
|
|
|
}
|
|
|
|
|
return 'helvetica'
|
|
|
|
|
}
|