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 { 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 { 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 { 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 { 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 { 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' }