From fd4cc29d536d5d6394b8ca7357d354aedc67bdac Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 14:45:38 +0200 Subject: [PATCH] feat: rewrite PDF renderer with 15 unique typographic layouts --- src/utils/invoicePdfRenderer.ts | 2793 +++++++++++++++++++------------ 1 file changed, 1759 insertions(+), 1034 deletions(-) diff --git a/src/utils/invoicePdfRenderer.ts b/src/utils/invoicePdfRenderer.ts index bfcb14e..50402ab 100644 --- a/src/utils/invoicePdfRenderer.ts +++ b/src/utils/invoicePdfRenderer.ts @@ -6,7 +6,7 @@ import type { InvoiceItem } from './invoicePdf' import { formatCurrency, formatDate } from './locale' // --------------------------------------------------------------------------- -// Public types +// Exported types // --------------------------------------------------------------------------- export interface BusinessInfo { @@ -17,1069 +17,1806 @@ export interface BusinessInfo { logo: string // base64 data URL or empty string } -// --------------------------------------------------------------------------- -// Color helpers -// --------------------------------------------------------------------------- - -function hexToRgb(hex: string): [number, number, number] { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return [r, g, b] -} - -function interpolateColor(hex1: string, hex2: string, t: number): string { - const [r1, g1, b1] = hexToRgb(hex1) - const [r2, g2, b2] = hexToRgb(hex2) - return '#' + [ - Math.round(r1 + (r2 - r1) * t), - Math.round(g1 + (g2 - g1) * t), - Math.round(b1 + (b2 - b1) * t), - ].map(v => v.toString(16).padStart(2, '0')).join('') -} - // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- -function setFill(doc: jsPDF, hex: string): void { - doc.setFillColor(hex) +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', '') + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + ] } -function setText(doc: jsPDF, hex: string): void { - doc.setTextColor(hex) +function setText(doc: jsPDF, hex: string) { + const [r, g, b] = hexToRgb(hex) + doc.setTextColor(r, g, b) } -function setDraw(doc: jsPDF, hex: string): void { - doc.setDrawColor(hex) +function setFill(doc: jsPDF, hex: string) { + const [r, g, b] = hexToRgb(hex) + doc.setFillColor(r, g, b) } -/** Draw a new-page background + sidebar if needed. */ -function drawPageBackground( - doc: jsPDF, - config: InvoiceTemplateConfig, - pageWidth: number, - pageHeight: number, -): void { - if (config.colors.background.toUpperCase() !== '#FFFFFF') { - setFill(doc, config.colors.background) - doc.rect(0, 0, pageWidth, pageHeight, 'F') - } - if (config.decorative.backgroundTint && config.decorative.backgroundTintColor) { - setFill(doc, config.decorative.backgroundTintColor) - doc.rect(0, 0, pageWidth, pageHeight, 'F') - } - if (config.decorative.sidebarWidth > 0) { - setFill(doc, config.decorative.sidebarColor) - doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F') - } +function setDraw(doc: jsPDF, hex: string) { + const [r, g, b] = hexToRgb(hex) + doc.setDrawColor(r, g, b) } -// --------------------------------------------------------------------------- -// Decorative elements -// --------------------------------------------------------------------------- - -function renderDecorative( - doc: jsPDF, - config: InvoiceTemplateConfig, - pageWidth: number, - pageHeight: number, -): void { - // Background tint (full page wash) - if (config.decorative.backgroundTint && config.decorative.backgroundTintColor) { - setFill(doc, config.decorative.backgroundTintColor) - doc.rect(0, 0, pageWidth, pageHeight, 'F') - } - - // Sidebar - if (config.decorative.sidebarWidth > 0) { - setFill(doc, config.decorative.sidebarColor) - doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F') - } - - // Corner shapes - if (config.decorative.cornerShape === 'colored-block') { - setFill(doc, config.colors.primary) - doc.rect(0, 0, 60, 60, 'F') - } else if (config.decorative.cornerShape === 'triangle') { - setFill(doc, config.colors.primary) - doc.triangle(pageWidth, 0, pageWidth, 80, pageWidth - 80, 0, 'F') - } else if (config.decorative.cornerShape === 'diagonal') { - setFill(doc, config.colors.primary) - doc.triangle(0, 0, pageWidth, 0, 0, 60, 'F') +function truncateDesc(doc: jsPDF, text: string, maxWidth: number): string { + if (doc.getTextWidth(text) <= maxWidth) return text + while (doc.getTextWidth(text + '...') > maxWidth && text.length > 0) { + text = text.slice(0, -1) } + return text + '...' } -// --------------------------------------------------------------------------- -// Logo rendering -// --------------------------------------------------------------------------- - -function renderLogo( - doc: jsPDF, - config: InvoiceTemplateConfig, - businessInfo: BusinessInfo, - y: number, - margin: number, - pageWidth: number, -): void { - if (!businessInfo.logo) return +function drawLogo(doc: jsPDF, logo: string, x: number, y: number, maxH: number): number { + if (!logo) return 0 try { - let logoX: number - if (config.layout.logoPosition === 'top-right') { - logoX = pageWidth - margin - 40 - } else if (config.layout.logoPosition === 'top-center') { - logoX = pageWidth / 2 - 20 - } else { - logoX = margin - } - doc.addImage(businessInfo.logo, 'AUTO', logoX, y, 40, 16) + doc.addImage(logo, 'PNG', x, y, 0, maxH) + return maxH + 2 } catch { - // silently skip if logo data is invalid + return 0 } } // --------------------------------------------------------------------------- -// Header styles +// Shared table column geometry // --------------------------------------------------------------------------- -function renderHeader( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, -): number { - switch (config.layout.headerStyle) { - case 'minimal': - return renderHeaderMinimal(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) - case 'full-width': - return renderHeaderFullWidth(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) - case 'split': - return renderHeaderSplit(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) - case 'sidebar': - return renderHeaderSidebar(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) - case 'gradient': - return renderHeaderGradient(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) - case 'geometric': - return renderHeaderGeometric(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) - case 'centered': - return renderHeaderCentered(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) - default: - return renderHeaderMinimal(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) +type ColLayout = { colX: number[]; colW: number[] } + +function tableLayout(startX: number, contentW: number): ColLayout { + const descW = contentW * 0.48 + const qtyW = contentW * 0.12 + const rateW = contentW * 0.20 + const amtW = contentW * 0.20 + return { + colW: [descW, qtyW, rateW, amtW], + colX: [startX, startX + descW, startX + descW + qtyW, startX + descW + qtyW + rateW], } } -// --- 1. Minimal header --- -function renderHeaderMinimal( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, -): number { - let y = startY +// Tracked uppercase helper +function trackedText(doc: jsPDF, str: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center' }) { + doc.setCharSpace(0.3) + doc.text(str, x, y, opts) + doc.setCharSpace(0) +} - // Logo - renderLogo(doc, config, businessInfo, y, margin, pageWidth) - if (businessInfo.logo) y += 20 - - // Business info - if (businessInfo.name) { - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.headerSize) - setText(doc, config.colors.headerText) - doc.text(businessInfo.name, margin, y) - y += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - if (businessInfo.address) { - const addrLines = businessInfo.address.split('\n') - for (const line of addrLines) { - doc.text(line, margin, y) - y += 4 - } - } - if (businessInfo.email) { doc.text(businessInfo.email, margin, y); y += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, margin, y); y += 4 } - y += 4 - } - - // Title "INVOICE" - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - setText(doc, config.colors.primary) - doc.text('INVOICE', margin, y + 8) - y += 12 - - // Accent line - setFill(doc, config.colors.primary) - doc.rect(margin, y, 40, 1, 'F') - y += 6 - - // Invoice details (left) and Bill To (right) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - setText(doc, config.colors.bodyText) - - const detailsY = y - doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 - doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 - if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } - doc.text(`Status: ${invoice.status}`, margin, y); y += 5 - - // Bill To (right side) - const rightX = pageWidth - margin - 60 - let clientY = detailsY +// Draw header row text (uppercase, tracked) +function drawHeaderText( + doc: jsPDF, layout: ColLayout, y: number, rowH: number, colorHex: string, padX: number, +) { doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text('Bill To:', rightX, clientY); clientY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 - if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } - if (client.address) { - const lines = client.address.split('\n') - for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } - } - - y = Math.max(y, clientY) + 10 - - // Divider - if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { - drawDivider(doc, config, margin, y, contentWidth) - y += 6 - } - - return y + doc.setFontSize(9) + setText(doc, colorHex) + const textY = y + rowH * 0.65 + trackedText(doc, 'DESCRIPTION', layout.colX[0] + padX, textY) + trackedText(doc, 'QTY', layout.colX[1] + padX, textY) + doc.text('RATE', layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) + doc.text('AMOUNT', layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) + doc.setCharSpace(0) } -// --- 2. Full-width header --- -function renderHeaderFullWidth( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - margin: number, - pageWidth: number, - _contentWidth: number, +// Draw a single item row (returns new y) +function drawItemRow( + doc: jsPDF, item: InvoiceItem, layout: ColLayout, y: number, rowH: number, + textColor: string, padX: number, ): number { - const headerHeight = 45 - - // Full-width colored band - setFill(doc, config.colors.headerBg) - doc.rect(0, 0, pageWidth, headerHeight, 'F') - - // Logo in the header - if (businessInfo.logo) { - try { - let logoX: number - if (config.layout.logoPosition === 'top-right') { - logoX = pageWidth - margin - 40 - } else if (config.layout.logoPosition === 'top-center') { - logoX = pageWidth / 2 - 20 - } else { - logoX = margin - } - doc.addImage(businessInfo.logo, 'AUTO', logoX, 4, 40, 16) - } catch { /* skip */ } - } - - let y = 14 - - // Title - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - setText(doc, config.colors.headerText) - doc.text('INVOICE', margin, y) - - // Invoice number below title - y += 8 doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(`#${invoice.invoice_number}`, margin, y) - y += 4 - doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) - y += 4 - if (invoice.due_date) { - doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) - y += 4 - } - doc.text(`Status: ${invoice.status}`, margin, y) - - // Business info (top right inside header) - if (businessInfo.name && config.layout.logoPosition !== 'top-right') { - const bx = pageWidth - margin - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text(businessInfo.name, bx, 14, { align: 'right' }) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize - 1) - let by = 19 - if (businessInfo.email) { doc.text(businessInfo.email, bx, by, { align: 'right' }); by += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, bx, by, { align: 'right' }); by += 4 } - if (businessInfo.address) { - const addrLines = businessInfo.address.split('\n') - for (const line of addrLines) { doc.text(line, bx, by, { align: 'right' }); by += 4 } - } - } - - // Bill To section below the header band - let billY = headerHeight + 10 - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - setText(doc, config.colors.bodyText) - doc.text('Bill To:', margin, billY); billY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', margin, billY); billY += 4 - if (client.email) { doc.text(client.email, margin, billY); billY += 4 } - if (client.address) { - const addrLines = client.address.split('\n') - for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } - } - - return billY + 10 + doc.setFontSize(10) + setText(doc, textColor) + const textY = y + rowH * 0.65 + const desc = truncateDesc(doc, item.description, layout.colW[0] - padX * 2) + doc.text(desc, layout.colX[0] + padX, textY) + doc.text(String(item.quantity), layout.colX[1] + padX, textY) + doc.text(formatCurrency(item.rate), layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) + doc.text(formatCurrency(item.amount), layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) + return y + rowH } -// --- 3. Split header --- -function renderHeaderSplit( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - margin: number, - pageWidth: number, - _contentWidth: number, +// Draw totals block (returns new y) +function drawTotals( + doc: jsPDF, invoice: Invoice, rightX: number, y: number, + textColor: string, totalColor: string, totalSize: number, ): number { - const headerHeight = 50 - const halfWidth = pageWidth / 2 - - // Left half: dark background - setFill(doc, config.colors.headerBg) - doc.rect(0, 0, halfWidth, headerHeight, 'F') - - // Left side: white text with business info + title - setText(doc, config.colors.headerText) - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - doc.text('INVOICE', margin, 18) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - let ly = 26 - if (businessInfo.name) { doc.text(businessInfo.name, margin, ly); ly += 4 } - if (businessInfo.email) { doc.text(businessInfo.email, margin, ly); ly += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, margin, ly); ly += 4 } - if (businessInfo.address) { - const addrLines = businessInfo.address.split('\n') - for (const line of addrLines) { doc.text(line, margin, ly); ly += 4 } - } - - // Logo inside left half - if (businessInfo.logo) { - try { - doc.addImage(businessInfo.logo, 'AUTO', margin, headerHeight - 20, 36, 14) - } catch { /* skip */ } - } - - // Right side: dark text with invoice details - const rightMargin = halfWidth + 10 - setText(doc, config.colors.bodyText) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - let ry = 14 - doc.text(`Invoice #: ${invoice.invoice_number}`, rightMargin, ry); ry += 5 - doc.text(`Date: ${formatDate(invoice.date)}`, rightMargin, ry); ry += 5 - if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, rightMargin, ry); ry += 5 } - doc.text(`Status: ${invoice.status}`, rightMargin, ry); ry += 5 - - // Bill To below header - let billY = headerHeight + 10 - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - setText(doc, config.colors.bodyText) - doc.text('Bill To:', margin, billY); billY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', margin, billY); billY += 4 - if (client.email) { doc.text(client.email, margin, billY); billY += 4 } - if (client.address) { - const addrLines = client.address.split('\n') - for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } - } - - return billY + 10 -} - -// --- 4. Sidebar header --- -function renderHeaderSidebar( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, -): number { - const sideOffset = config.decorative.sidebarWidth + 6 - const leftX = sideOffset + margin - let y = startY - - // Logo - if (businessInfo.logo) { - try { - doc.addImage(businessInfo.logo, 'AUTO', leftX, y, 40, 16) - } catch { /* skip */ } - y += 20 - } - - // Business info - if (businessInfo.name) { - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.headerSize) - setText(doc, config.colors.headerText) - doc.text(businessInfo.name, leftX, y); y += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - if (businessInfo.address) { - const addrLines = businessInfo.address.split('\n') - for (const line of addrLines) { doc.text(line, leftX, y); y += 4 } - } - if (businessInfo.email) { doc.text(businessInfo.email, leftX, y); y += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, leftX, y); y += 4 } - y += 4 - } - - // Title - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - setText(doc, config.colors.primary) - doc.text('INVOICE', leftX, y + 8); y += 14 - - // Invoice details - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - setText(doc, config.colors.bodyText) - const detailsY = y - doc.text(`Invoice #: ${invoice.invoice_number}`, leftX, y); y += 5 - doc.text(`Date: ${formatDate(invoice.date)}`, leftX, y); y += 5 - if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, leftX, y); y += 5 } - doc.text(`Status: ${invoice.status}`, leftX, y); y += 5 - - // Bill To (right) - const rightX = pageWidth - margin - 60 - let clientY = detailsY - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text('Bill To:', rightX, clientY); clientY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 - if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } - if (client.address) { - const lines = client.address.split('\n') - for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } - } - - y = Math.max(y, clientY) + 10 - - // Divider - if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { - drawDivider(doc, config, leftX, y, contentWidth - sideOffset) - y += 6 - } - - return y -} - -// --- 5. Gradient header --- -function renderHeaderGradient( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - margin: number, - pageWidth: number, - _contentWidth: number, -): number { - const gradientHeight = 45 - const from = config.decorative.gradientFrom || config.colors.headerBg - const to = config.decorative.gradientTo || config.colors.primary - - // Draw gradient with 30 strips - const steps = 30 - const stripH = gradientHeight / steps - for (let i = 0; i < steps; i++) { - const t = i / steps - setFill(doc, interpolateColor(from, to, t)) - doc.rect(0, stripH * i, pageWidth, stripH + 0.5, 'F') - } - - // Logo - if (businessInfo.logo) { - try { - let logoX: number - if (config.layout.logoPosition === 'top-right') { - logoX = pageWidth - margin - 40 - } else if (config.layout.logoPosition === 'top-center') { - logoX = pageWidth / 2 - 20 - } else { - logoX = margin - } - doc.addImage(businessInfo.logo, 'AUTO', logoX, 4, 40, 16) - } catch { /* skip */ } - } - - // White text overlay - setText(doc, config.colors.headerText) - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - doc.text('INVOICE', margin, 18) - - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - let y = 26 - doc.text(`#${invoice.invoice_number}`, margin, y); y += 4 - doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 4 - if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y); y += 4 } - doc.text(`Status: ${invoice.status}`, margin, y) - - // Business info on right inside gradient - if (businessInfo.name && config.layout.logoPosition !== 'top-right') { - const bx = pageWidth - margin - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text(businessInfo.name, bx, 14, { align: 'right' }) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize - 1) - let by = 19 - if (businessInfo.email) { doc.text(businessInfo.email, bx, by, { align: 'right' }); by += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, bx, by, { align: 'right' }); by += 4 } - } - - // Bill To below gradient - let billY = gradientHeight + 10 - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - setText(doc, config.colors.bodyText) - doc.text('Bill To:', margin, billY); billY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', margin, billY); billY += 4 - if (client.email) { doc.text(client.email, margin, billY); billY += 4 } - if (client.address) { - const addrLines = client.address.split('\n') - for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } - } - - return billY + 10 -} - -// --- 6. Geometric header --- -function renderHeaderGeometric( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, -): number { - // Triangle is already drawn by renderDecorative. - // Content placed avoiding the top-right triangle area. - let y = startY - - // Logo (top-left to avoid triangle) - if (businessInfo.logo) { - try { - doc.addImage(businessInfo.logo, 'AUTO', margin, y, 40, 16) - } catch { /* skip */ } - y += 20 - } - - // Business info - if (businessInfo.name) { - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.headerSize) - setText(doc, config.colors.headerText) - doc.text(businessInfo.name, margin, y); y += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - if (businessInfo.email) { doc.text(businessInfo.email, margin, y); y += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, margin, y); y += 4 } - y += 4 - } - - // Title below triangle area - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - setText(doc, config.colors.primary) - doc.text('INVOICE', margin, y + 8); y += 14 - - // Invoice details - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - setText(doc, config.colors.bodyText) - const detailsY = y - doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 - doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 - if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } - doc.text(`Status: ${invoice.status}`, margin, y); y += 5 - - // Bill To (right, offset down to avoid triangle) - const rightX = pageWidth - margin - 60 - let clientY = Math.max(detailsY, 50) // stay below the triangle - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text('Bill To:', rightX, clientY); clientY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 - if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } - if (client.address) { - const lines = client.address.split('\n') - for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } - } - - y = Math.max(y, clientY) + 10 - - // Divider - if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { - drawDivider(doc, config, margin, y, contentWidth) - y += 6 - } - - return y -} - -// --- 7. Centered header --- -function renderHeaderCentered( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - client: Client, - businessInfo: BusinessInfo, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, -): number { - const centerX = pageWidth / 2 - let y = startY - - // Logo centered - if (businessInfo.logo) { - try { - doc.addImage(businessInfo.logo, 'AUTO', centerX - 20, y, 40, 16) - } catch { /* skip */ } - y += 20 - } - - // Top thin rule - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.3) - doc.line(margin, y, margin + contentWidth, y) - y += 6 - - // Title centered - doc.setFont('helvetica', config.typography.titleWeight) - doc.setFontSize(config.typography.titleSize) - setText(doc, config.colors.primary) - doc.text('INVOICE', centerX, y + 6, { align: 'center' }) - y += 12 - - // Business name centered - if (businessInfo.name) { - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.headerSize) - setText(doc, config.colors.headerText) - doc.text(businessInfo.name, centerX, y, { align: 'center' }) - y += 5 - doc.setFontSize(config.typography.bodySize) - if (businessInfo.address) { - const addrLines = businessInfo.address.split('\n') - for (const line of addrLines) { - doc.text(line, centerX, y, { align: 'center' }) - y += 4 - } - } - if (businessInfo.email) { doc.text(businessInfo.email, centerX, y, { align: 'center' }); y += 4 } - if (businessInfo.phone) { doc.text(businessInfo.phone, centerX, y, { align: 'center' }); y += 4 } - y += 2 - } - - // Bottom thin rule - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.3) - doc.line(margin, y, margin + contentWidth, y) - y += 8 - - // Invoice details (left) and Bill To (right) - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - setText(doc, config.colors.bodyText) - - const detailsY = y - doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 - doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 - if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } - doc.text(`Status: ${invoice.status}`, margin, y); y += 5 - - const rightX = pageWidth - margin - 60 - let clientY = detailsY - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - doc.text('Bill To:', rightX, clientY); clientY += 5 - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 - if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } - if (client.address) { - const lines = client.address.split('\n') - for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } - } - - y = Math.max(y, clientY) + 10 - - // Divider (double style for centered template) - if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { - drawDivider(doc, config, margin, y, contentWidth) - y += 6 - } - - return y -} - -// --------------------------------------------------------------------------- -// Divider helper -// --------------------------------------------------------------------------- - -function drawDivider( - doc: jsPDF, - config: InvoiceTemplateConfig, - x: number, - y: number, - width: number, -): void { - setDraw(doc, config.colors.tableBorder) - switch (config.layout.dividerStyle) { - case 'thin': - doc.setLineWidth(0.3) - doc.line(x, y, x + width, y) - break - case 'double': - doc.setLineWidth(0.3) - doc.line(x, y, x + width, y) - doc.line(x, y + 1.5, x + width, y + 1.5) - break - case 'thick': - doc.setLineWidth(1) - doc.line(x, y, x + width, y) - break - case 'none': - break - } - doc.setLineWidth(0.2) // reset -} - -// --------------------------------------------------------------------------- -// Line items table -// --------------------------------------------------------------------------- - -function renderTable( - doc: jsPDF, - config: InvoiceTemplateConfig, - items: InvoiceItem[], - startY: number, - margin: number, - contentWidth: number, - pageWidth: number, - pageHeight: number, -): number { - const tableStyle = config.layout.tableStyle - const typography = config.typography - - // Compute left offset for sidebar layouts - const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 - const tableX = margin + sideOffset - const tableW = contentWidth - sideOffset - - // Column widths (Description 50%, Qty 15%, Rate 17.5%, Amount 17.5%) - const colDesc = tableW * 0.50 - const colQty = tableW * 0.15 - const colRate = tableW * 0.175 - // colAmt = tableW * 0.175 (last column, no offset needed) - const rowHeight = 8 - - let y = startY - - // ---- Table header row ---- - setFill(doc, config.colors.tableHeaderBg) - doc.rect(tableX, y, tableW, rowHeight, 'F') - - doc.setFont('helvetica', 'bold') - doc.setFontSize(typography.bodySize) - setText(doc, config.colors.tableHeaderText) - - let hx = tableX + 2 - doc.text('Description', hx, y + 5.5) - hx += colDesc - - // Monospace font for number columns if configured - if (typography.numberStyle === 'monospace-feel') doc.setFont('courier', 'bold') - doc.text('Qty', hx, y + 5.5) - hx += colQty - doc.text('Rate', hx, y + 5.5) - hx += colRate - doc.text('Amount', hx, y + 5.5) - - // Draw header borders for bordered style - if (tableStyle === 'bordered') { - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.3) - doc.rect(tableX, y, tableW, rowHeight, 'S') - // Vertical lines - let vx = tableX + colDesc - doc.line(vx, y, vx, y + rowHeight) - vx += colQty - doc.line(vx, y, vx, y + rowHeight) - vx += colRate - doc.line(vx, y, vx, y + rowHeight) - } - - y += rowHeight - - // ---- Table data rows ---- - for (let idx = 0; idx < items.length; idx++) { - // Page break check - if (y > 260) { - doc.addPage() - drawPageBackground(doc, config, pageWidth, pageHeight) - y = margin - } - - const item = items[idx] - const isAlt = idx % 2 === 1 - - // Row background based on table style - if (tableStyle === 'striped' && isAlt) { - setFill(doc, config.colors.tableRowAlt) - doc.rect(tableX, y, tableW, rowHeight, 'F') - } else if (tableStyle === 'colored-sections') { - setFill(doc, isAlt ? config.colors.tableRowAlt : config.colors.background) - doc.rect(tableX, y, tableW, rowHeight, 'F') - } else if (tableStyle === 'bordered') { - // Draw cell borders - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.3) - doc.rect(tableX, y, tableW, rowHeight, 'S') - let vx = tableX + colDesc - doc.line(vx, y, vx, y + rowHeight) - vx += colQty - doc.line(vx, y, vx, y + rowHeight) - vx += colRate - doc.line(vx, y, vx, y + rowHeight) - } - - // Row text - doc.setFont('helvetica', 'normal') - doc.setFontSize(typography.bodySize) - setText(doc, config.colors.bodyText) - - let rx = tableX + 2 - // Truncate description to fit - const descText = item.description.length > 50 ? item.description.substring(0, 47) + '...' : item.description - doc.text(descText, rx, y + 5.5) - rx += colDesc - - // Number columns: optionally monospace - if (typography.numberStyle === 'monospace-feel') doc.setFont('courier', 'normal') - doc.text(item.quantity.toString(), rx, y + 5.5) - rx += colQty - doc.text(formatCurrency(item.rate), rx, y + 5.5) - rx += colRate - doc.text(formatCurrency(item.amount), rx, y + 5.5) - - // Reset font - doc.setFont('helvetica', 'normal') - - // Minimal-lines: draw horizontal line after row - if (tableStyle === 'minimal-lines') { - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.2) - doc.line(tableX, y + rowHeight, tableX + tableW, y + rowHeight) - } - - y += rowHeight - } - - return y + 10 -} - -// --------------------------------------------------------------------------- -// Totals section -// --------------------------------------------------------------------------- - -function renderTotals( - doc: jsPDF, - config: InvoiceTemplateConfig, - invoice: Invoice, - startY: number, - margin: number, - pageWidth: number, - contentWidth: number, - pageHeight: number, -): number { - let y = startY - - // Page break check - if (y > 250) { - doc.addPage() - drawPageBackground(doc, config, pageWidth, pageHeight) - y = margin - } - - const blockWidth = 70 - - let labelX: number - let valueX: number - - if (config.layout.totalsPosition === 'center') { - const cx = pageWidth / 2 - labelX = cx - blockWidth / 2 - valueX = cx + blockWidth / 2 - } else { - // Right-aligned - const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 - labelX = margin + sideOffset + contentWidth - sideOffset - blockWidth - valueX = margin + sideOffset + contentWidth - sideOffset - } - - doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize + 1) - setText(doc, config.colors.bodyText) - - const numFont = config.typography.numberStyle === 'monospace-feel' ? 'courier' : 'helvetica' - - // Subtotal - doc.text('Subtotal:', labelX, y) - doc.setFont(numFont, 'normal') - doc.text(formatCurrency(invoice.subtotal), valueX, y, { align: 'right' }) - doc.setFont('helvetica', 'normal') - y += 6 - - // Tax + doc.setFontSize(10) + setText(doc, textColor) + doc.text(`Subtotal`, rightX - 50, y) + doc.text(formatCurrency(invoice.subtotal), rightX, y, { align: 'right' }) + y += 7 if (invoice.tax_rate > 0) { - doc.text(`Tax (${invoice.tax_rate}%):`, labelX, y) - doc.setFont(numFont, 'normal') - doc.text(formatCurrency(invoice.tax_amount), valueX, y, { align: 'right' }) - doc.setFont('helvetica', 'normal') - y += 6 + doc.text(`Tax (${invoice.tax_rate}%)`, rightX - 50, y) + doc.text(formatCurrency(invoice.tax_amount), rightX, y, { align: 'right' }) + y += 7 } - - // Discount if (invoice.discount > 0) { - doc.text('Discount:', labelX, y) - doc.setFont(numFont, 'normal') - doc.text(`-${formatCurrency(invoice.discount)}`, valueX, y, { align: 'right' }) - doc.setFont('helvetica', 'normal') - y += 6 + doc.text(`Discount`, rightX - 50, y) + doc.text(`-${formatCurrency(invoice.discount)}`, rightX, y, { align: 'right' }) + y += 7 } - - // Separator line - y += 2 - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.4) - doc.line(labelX - 2, y, valueX, y) - y += 6 - - // Total + y += 3 doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 3) - setText(doc, config.colors.totalHighlight) - doc.text('Total:', labelX, y) - doc.setFont(numFont, 'bold') - doc.text(formatCurrency(invoice.total), valueX, y, { align: 'right' }) - - y += 12 + doc.setFontSize(totalSize) + setText(doc, totalColor) + doc.text('TOTAL', rightX - 50, y) + doc.text(formatCurrency(invoice.total), rightX, y, { align: 'right' }) return y } -// --------------------------------------------------------------------------- -// Notes section -// --------------------------------------------------------------------------- +// Draw notes section (returns new y) +function drawNotes(doc: jsPDF, invoice: Invoice, margin: number, contentW: number, y: number, textColor: string): number { + if (!invoice.notes) return y + if (y > 260) { doc.addPage(); y = 20 } + doc.setFont('helvetica', 'normal') + doc.setFontSize(8) + setText(doc, textColor) + doc.text('Notes:', margin, y) + y += 5 + const noteLines = doc.splitTextToSize(invoice.notes, contentW) + doc.text(noteLines, margin, y) + y += noteLines.length * 4 + return y +} -function renderNotes( - doc: jsPDF, - config: InvoiceTemplateConfig, - notes: string, - startY: number, - margin: number, - contentWidth: number, - pageWidth: number, - pageHeight: number, -): number { - let y = startY +// Draw From column (business info) +function drawFromColumn(doc: jsPDF, biz: BusinessInfo, x: number, y: number, textColor: string, labelColor: string, maxW: number): number { + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, labelColor) + trackedText(doc, 'FROM', x, y) + y += 6 + doc.setFont('helvetica', 'bold') + doc.setFontSize(10) + setText(doc, textColor) + if (biz.name) { doc.text(biz.name, x, y); y += 5 } + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (biz.address) { + const lines = doc.splitTextToSize(biz.address, maxW) + doc.text(lines, x, y) + y += lines.length * 5 + } + if (biz.email) { doc.text(biz.email, x, y); y += 5 } + if (biz.phone) { doc.text(biz.phone, x, y); y += 5 } + return y +} - // Page break check - if (y > 240) { +// Draw To column (client info) +function drawToColumn(doc: jsPDF, client: Client, x: number, y: number, textColor: string, labelColor: string, maxW: number): number { + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, labelColor) + trackedText(doc, 'TO', x, y) + y += 6 + doc.setFont('helvetica', 'bold') + doc.setFontSize(10) + setText(doc, textColor) + if (client.name) { doc.text(client.name, x, y); y += 5 } + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (client.email) { doc.text(client.email, x, y); y += 5 } + if (client.address) { + const lines = doc.splitTextToSize(client.address, maxW) + doc.text(lines, x, y) + y += lines.length * 5 + } + return y +} + +// Page-break guard (returns true if page was added) +function pageBreak(doc: jsPDF, y: number, threshold: number = 262): boolean { + if (y > threshold) { doc.addPage() - drawPageBackground(doc, config, pageWidth, pageHeight) - y = margin + return true + } + return false +} + + +// =========================================================================== +// 1. CLEAN — Swiss minimalism, single blue accent +// =========================================================================== + +function renderClean( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Header: logo + biz name --- + let y = 20 + const logoH = drawLogo(doc, biz.logo, margin, y, 16) + y += logoH + + doc.setFont('helvetica', 'bold') + doc.setFontSize(16) + setText(doc, c.primary) + doc.text(biz.name || '', margin, y) + y += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, margin, y); y += 5 } + if (biz.email) { doc.text(biz.email, margin, y); y += 5 } + if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } + + // --- INVOICE title + accent line --- + y += 4 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', margin, y) + y += 3 + // 30% width accent line in secondary color + setFill(doc, c.secondary) + doc.rect(margin, y, contentW * 0.3, 0.8, 'F') + y += 8 + + // --- Invoice meta --- + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, margin, y) + doc.text(`Date: ${formatDate(invoice.date)}`, margin + 50, y) + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin + 105, y) + } + y += 10 + + // --- From / To columns --- + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header bg + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + // Thin bottom border + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin, y + rowH, margin + contentW, y + rowH) + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } - const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 - const leftX = margin + sideOffset - const noteWidth = contentWidth - sideOffset + // --- Totals --- + y += 10 + const totalRightX = margin + contentW + y = drawTotals(doc, invoice, totalRightX, y, c.bodyText, c.secondary, 18) - // Divider before notes - setDraw(doc, config.colors.tableBorder) - doc.setLineWidth(0.2) - doc.line(leftX, y, leftX + noteWidth, y) + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 2. PROFESSIONAL — Navy header band, corporate polish +// =========================================================================== + +function renderProfessional( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Full-width navy header rect --- + setFill(doc, c.headerBg) + doc.rect(0, 0, 210, 50, 'F') + + // "INVOICE" white inside + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.headerText) + doc.text('INVOICE', margin, 22) + + // Biz name white right side + doc.setFontSize(14) + doc.text(biz.name || '', pageW - margin, 20, { align: 'right' }) + + // Invoice meta white inside band + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + doc.text(`# ${invoice.invoice_number}`, margin, 32) + doc.text(`Date: ${formatDate(invoice.date)}`, margin, 38) + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, 44) + } + + // Biz details right side in header + doc.setFontSize(9) + let bizY = 28 + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + // --- From / To --- + let y = 60 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Navy header row + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows with gray zebra + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 20 + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 3. BOLD — Large indigo block with oversized typography +// =========================================================================== + +function renderBold( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Large indigo rect --- + setFill(doc, c.headerBg) + doc.rect(0, 0, 115, 50, 'F') + + // "INVOICE" 32pt white inside block + doc.setFont('helvetica', 'bold') + doc.setFontSize(32) + setText(doc, c.headerText) + doc.text('INVOICE', margin, 25) + + // Invoice # white inside block + doc.setFont('helvetica', 'normal') + doc.setFontSize(11) + doc.text(`# ${invoice.invoice_number}`, margin, 37) + + // Biz info outside block to the right + let bizY = 14 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.bodyText) + doc.text(biz.name || '', 125, bizY) + bizY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (biz.address) { doc.text(biz.address, 125, bizY); bizY += 5 } + if (biz.email) { doc.text(biz.email, 125, bizY); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, 125, bizY); bizY += 5 } + + // Date and due date outside block + setText(doc, c.bodyText) + doc.setFontSize(10) + doc.text(`Date: ${formatDate(invoice.date)}`, 125, bizY) + bizY += 5 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, 125, bizY) + } + + // --- FROM / TO with tracked labels --- + let y = 60 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table --- + const layout = tableLayout(margin, contentW) + const rowH = 10 + const padX = 4 + + // Indigo header row + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows — no borders, no stripes, generous height + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) + } + + // --- Totals --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 4. MINIMAL — Pure monochrome, everything centered +// =========================================================================== + +function renderMinimal( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const cx = pageW / 2 + const c = config.colors + + // --- "INVOICE" 28pt centered charcoal --- + let y = 28 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', cx, y, { align: 'center' }) y += 6 - // Label - doc.setFont('helvetica', 'bold') - doc.setFontSize(config.typography.bodySize + 1) - setText(doc, config.colors.bodyText) - doc.text('Notes:', leftX, y) + // Biz name centered + doc.setFontSize(14) + setText(doc, c.headerText) + doc.text(biz.name || '', cx, y, { align: 'center' }) y += 5 - // Wrapped note text + // Thin rule + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin + 20, y, pageW - margin - 20, y) + y += 6 + + // Biz details centered doc.setFont('helvetica', 'normal') - doc.setFontSize(config.typography.bodySize) - const noteLines: string[] = doc.splitTextToSize(notes, noteWidth) - for (const line of noteLines) { - if (y > 280) { - doc.addPage() - drawPageBackground(doc, config, pageWidth, pageHeight) - y = margin - } - doc.text(line, leftX, y) - y += 4.5 + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, cx, y, { align: 'center' }); y += 5 } + if (biz.email) { doc.text(biz.email, cx, y, { align: 'center' }); y += 5 } + if (biz.phone) { doc.text(biz.phone, cx, y, { align: 'center' }); y += 5 } + + // Invoice meta centered + y += 2 + doc.text(`Invoice # ${invoice.invoice_number} | ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, cx, y, { align: 'center' }) + y += 4 + + // Thin rule + doc.line(margin + 20, y, pageW - margin - 20, y) + y += 8 + + // --- From / To centered columns --- + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: no backgrounds, header bold only --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header text only (no bg) + drawHeaderText(doc, layout, y, rowH, c.primary, padX) + y += rowH + + // Rows — whitespace separation only + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } - return y + 5 + // --- Totals centered-right --- + y += 10 + // Centered total + doc.setFont('helvetica', 'bold') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`Subtotal: ${formatCurrency(invoice.subtotal)}`, cx, y, { align: 'center' }) + y += 7 + if (invoice.tax_rate > 0) { + doc.setFont('helvetica', 'normal') + doc.text(`Tax (${invoice.tax_rate}%): ${formatCurrency(invoice.tax_amount)}`, cx, y, { align: 'center' }) + y += 7 + } + if (invoice.discount > 0) { + doc.setFont('helvetica', 'normal') + doc.text(`Discount: -${formatCurrency(invoice.discount)}`, cx, y, { align: 'center' }) + y += 7 + } + y += 3 + doc.setFont('helvetica', 'bold') + doc.setFontSize(18) + setText(doc, c.primary) + doc.text(formatCurrency(invoice.total), cx, y, { align: 'center' }) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc } -// --------------------------------------------------------------------------- -// Main render function -// --------------------------------------------------------------------------- + +// =========================================================================== +// 5. CLASSIC — Traditional layout, burgundy accents, bordered grid +// =========================================================================== + +function renderClassic( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Two-column header: biz left, INVOICE + meta right --- + let y = 20 + const logoH = drawLogo(doc, biz.logo, margin, y, 16) + y += logoH + + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.bodyText) + doc.text(biz.name || '', margin, y) + y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (biz.address) { doc.text(biz.address, margin, y); y += 5 } + if (biz.email) { doc.text(biz.email, margin, y); y += 5 } + if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } + + // Right: INVOICE + meta + let ry = 20 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', pageW - margin, ry, { align: 'right' }) + ry += 8 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, pageW - margin, ry, { align: 'right' }) + ry += 6 + doc.text(`Date: ${formatDate(invoice.date)}`, pageW - margin, ry, { align: 'right' }) + ry += 6 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, ry, { align: 'right' }) + } + + // Thin burgundy rule + y = Math.max(y, ry) + 4 + setDraw(doc, c.primary) + doc.setLineWidth(1) + doc.line(margin, y, pageW - margin, y) + y += 8 + + // --- From / To --- + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: bordered grid, burgundy header --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header bg + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + + // Header border + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + doc.rect(margin, y, contentW, rowH, 'S') + y += rowH + + // Rows with bordered grid + alternating warm gray + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 20 + // Alternating bg + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + // Cell borders + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + for (let col = 0; col < 4; col++) { + doc.rect(layout.colX[col], y - rowH, layout.colW[col], rowH, 'S') + } + } + + // --- Totals --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 6. MODERN — Teal accents, borderless, teal header text +// =========================================================================== + +function renderModern( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- "INVOICE" 28pt top-left --- + let y = 28 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.headerText) + doc.text('INVOICE', margin, y) + y += 8 + + // Invoice meta below + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, margin, y) + y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) + y += 5 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 5 + } + + // Biz info right side + let bizY = 22 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.bodyText) + doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) + bizY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + // Thin teal line separator + y = Math.max(y, bizY) + 4 + setDraw(doc, c.primary) + doc.setLineWidth(0.5) + doc.line(margin, y, pageW - margin, y) + y += 8 + + // --- From / To --- + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: borderless, teal bottom borders, teal header TEXT (no bg) --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header (teal text, no bg) + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + // Teal line below header + setDraw(doc, c.primary) + doc.setLineWidth(0.5) + doc.line(margin, y, margin + contentW, y) + + // Rows with teal bottom borders + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.2) + doc.line(margin, y, margin + contentW, y) + } + + // --- Totals with subtle teal bg strip --- + y += 10 + const totalRightX = margin + contentW + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text('Subtotal', totalRightX - 50, y) + doc.text(formatCurrency(invoice.subtotal), totalRightX, y, { align: 'right' }) + y += 7 + if (invoice.tax_rate > 0) { + doc.text(`Tax (${invoice.tax_rate}%)`, totalRightX - 50, y) + doc.text(formatCurrency(invoice.tax_amount), totalRightX, y, { align: 'right' }) + y += 7 + } + if (invoice.discount > 0) { + doc.text('Discount', totalRightX - 50, y) + doc.text(`-${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) + y += 7 + } + y += 3 + // Subtle teal bg strip + setFill(doc, c.tableRowAlt) + doc.rect(totalRightX - 65, y - 5, 65, 10, 'F') + doc.setFont('helvetica', 'bold') + doc.setFontSize(18) + setText(doc, c.totalHighlight) + doc.text('TOTAL', totalRightX - 60, y) + doc.text(formatCurrency(invoice.total), totalRightX, y, { align: 'right' }) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 7. ELEGANT — Gold double-rule accents, centered layout +// =========================================================================== + +function renderElegant( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const cx = pageW / 2 + const c = config.colors + const gold = c.primary + + // --- Gold double-rule at top --- + setFill(doc, gold) + doc.rect(margin, 15, contentW, 0.4, 'F') + doc.rect(margin, 16.5, contentW, 0.4, 'F') + + // "INVOICE" centered + let y = 26 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.headerText) + doc.text('INVOICE', cx, y, { align: 'center' }) + y += 7 + + // Biz centered + doc.setFontSize(14) + doc.text(biz.name || '', cx, y, { align: 'center' }) + y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, cx, y, { align: 'center' }); y += 5 } + if (biz.email) { doc.text(biz.email, cx, y, { align: 'center' }); y += 5 } + if (biz.phone) { doc.text(biz.phone, cx, y, { align: 'center' }); y += 5 } + + // Another double-rule + y += 2 + setFill(doc, gold) + doc.rect(margin, y, contentW, 0.4, 'F') + doc.rect(margin, y + 1.5, contentW, 0.4, 'F') + y += 8 + + // Invoice meta left + Bill To right + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + const metaY = y + doc.text(`Invoice: ${invoice.invoice_number}`, margin, y); y += 6 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 6 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, gold) + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 6 + } + + // Bill To right + let billY = metaY + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, gold) + trackedText(doc, 'BILL TO', pageW - margin, billY, { align: 'right' }) + billY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (client.name) { doc.text(client.name, pageW - margin, billY, { align: 'right' }); billY += 5 } + if (client.email) { doc.text(client.email, pageW - margin, billY, { align: 'right' }); billY += 5 } + if (client.address) { doc.text(client.address, pageW - margin, billY, { align: 'right' }); billY += 5 } + + y = Math.max(y, billY) + 8 + + // --- Table: gold double-rule above/below header, single gold rules between rows --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Double-rule above header + setFill(doc, gold) + doc.rect(margin, y, contentW, 0.4, 'F') + doc.rect(margin, y + 1.5, contentW, 0.4, 'F') + y += 3 + + drawHeaderText(doc, layout, y, rowH, c.headerText, padX) + y += rowH + + // Double-rule below header + setFill(doc, gold) + doc.rect(margin, y, contentW, 0.4, 'F') + doc.rect(margin, y + 1.5, contentW, 0.4, 'F') + y += 3 + + // Rows with single gold rules + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) + // Single gold line + setFill(doc, gold) + doc.rect(margin, y, contentW, 0.25, 'F') + y += 1 + } + + // --- Totals --- + y += 8 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, gold, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 8. CREATIVE — Purple sidebar, card-style rows +// =========================================================================== + +function renderCreative( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const pageW = 210 + const c = config.colors + + // --- Narrow purple rect full page height --- + setFill(doc, c.primary) + doc.rect(0, 0, 6, 297, 'F') + + // Content offset 14mm from left + const leftM = 14 + const rightM = 20 + const contentW = pageW - leftM - rightM + + // --- "INVOICE" in purple --- + let y = 28 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', leftM, y) + y += 8 + + // Invoice meta + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number} | ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, leftM, y) + y += 8 + + // Biz info right side + let bizY = 22 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.headerText) + doc.text(biz.name || '', pageW - rightM, bizY, { align: 'right' }) + bizY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } + + y = Math.max(y, bizY) + 4 + + // --- From / To --- + const fromEndY = drawFromColumn(doc, biz, leftM, y, c.bodyText, c.primary, 65) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 65) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: card-like rows --- + const layout = tableLayout(leftM, contentW) + const rowH = 10 + const padX = 4 + + // Header + doc.setFont('helvetica', 'bold') + doc.setFontSize(9) + setText(doc, c.tableHeaderText) + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows as faint purple bg cards + for (const item of items) { + if (pageBreak(doc, y)) { + y = 20 + // Redraw sidebar on new page + setFill(doc, c.primary) + doc.rect(0, 0, 6, 297, 'F') + } + // Card bg + setFill(doc, c.tableRowAlt) + doc.rect(leftM, y + 1, contentW, rowH - 2, 'F') + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) + } + + // --- Totals --- + y += 10 + y = drawTotals(doc, invoice, leftM + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, leftM, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 9. COMPACT — Data-dense layout with tight spacing +// =========================================================================== + +function renderCompact( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 15 // tighter margins + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Single-line header: biz name left, INVOICE #XXX right --- + let y = 18 + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.headerText) + doc.text(biz.name || '', margin, y) + doc.text(`INVOICE #${invoice.invoice_number}`, pageW - margin, y, { align: 'right' }) + y += 5 + + // Date and due right below + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + setText(doc, c.bodyText) + if (biz.email) doc.text(biz.email, margin, y) + doc.text(`Date: ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, pageW - margin, y, { align: 'right' }) + y += 5 + if (biz.phone) doc.text(biz.phone, margin, y) + y += 7 + + // --- Tight From/To --- + doc.setFont('helvetica', 'bold') + doc.setFontSize(9) + setText(doc, c.primary) + trackedText(doc, 'FROM', margin, y) + trackedText(doc, 'TO', pageW / 2 + 5, y) + y += 5 + + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + setText(doc, c.bodyText) + let fromY = y + if (biz.name) { doc.text(biz.name, margin, fromY); fromY += 4 } + if (biz.address) { doc.text(biz.address, margin, fromY); fromY += 4 } + + let toY = y + if (client.name) { doc.text(client.name, pageW / 2 + 5, toY); toY += 4 } + if (client.email) { doc.text(client.email, pageW / 2 + 5, toY); toY += 4 } + if (client.address) { doc.text(client.address, pageW / 2 + 5, toY); toY += 4 } + + y = Math.max(fromY, toY) + 6 + + // --- Table: tight zebra, 7mm rows, no borders --- + const layout = tableLayout(margin, contentW) + const rowH = 7 + const padX = 3 + + // Header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + doc.setFont('helvetica', 'bold') + doc.setFontSize(8) + setText(doc, c.tableHeaderText) + const headerTextY = y + rowH * 0.65 + trackedText(doc, 'DESCRIPTION', layout.colX[0] + padX, headerTextY) + trackedText(doc, 'QTY', layout.colX[1] + padX, headerTextY) + doc.text('RATE', layout.colX[2] + layout.colW[2] - padX, headerTextY, { align: 'right' }) + doc.text('AMOUNT', layout.colX[3] + layout.colW[3] - padX, headerTextY, { align: 'right' }) + doc.setCharSpace(0) + y += rowH + + // Rows + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 15 + if (i % 2 === 0) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + setText(doc, c.bodyText) + const textY = y + rowH * 0.62 + const desc = truncateDesc(doc, items[i].description, layout.colW[0] - padX * 2) + doc.text(desc, layout.colX[0] + padX, textY) + doc.text(String(items[i].quantity), layout.colX[1] + padX, textY) + doc.text(formatCurrency(items[i].rate), layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) + doc.text(formatCurrency(items[i].amount), layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) + y += rowH + } + + // --- Totals (subtle, right-aligned) --- + y += 6 + const totalRightX = margin + contentW + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + setText(doc, c.bodyText) + doc.text(`Subtotal: ${formatCurrency(invoice.subtotal)}`, totalRightX, y, { align: 'right' }) + y += 5 + if (invoice.tax_rate > 0) { + doc.text(`Tax (${invoice.tax_rate}%): ${formatCurrency(invoice.tax_amount)}`, totalRightX, y, { align: 'right' }) + y += 5 + } + if (invoice.discount > 0) { + doc.text(`Discount: -${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) + y += 5 + } + y += 2 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.totalHighlight) + doc.text(`Total: ${formatCurrency(invoice.total)}`, totalRightX, y, { align: 'right' }) + + // --- Notes --- + y += 10 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 10. DARK — Full dark background with cyan highlights +// =========================================================================== + +function renderDark( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Full-page dark bg --- + setFill(doc, c.background) + doc.rect(0, 0, 210, 297, 'F') + + // "INVOICE" 28pt cyan + let y = 28 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', margin, y) + y += 8 + + // Invoice meta in light text + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, margin, y) + y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) + y += 5 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 5 + } + + // Biz info right in light text + let bizY = 22 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.headerText) + doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) + bizY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + y = Math.max(y, bizY) + 6 + + // --- From / To in light text --- + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, c.primary) + trackedText(doc, 'FROM', margin, y) + trackedText(doc, 'TO', pageW / 2 + 5, y) + y += 6 + + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + let fromY = y + if (biz.name) { doc.text(biz.name, margin, fromY); fromY += 5 } + if (biz.address) { doc.text(biz.address, margin, fromY); fromY += 5 } + if (biz.email) { doc.text(biz.email, margin, fromY); fromY += 5 } + + let toY = y + setText(doc, c.headerText) + if (client.name) { doc.text(client.name, pageW / 2 + 5, toY); toY += 5 } + setText(doc, c.bodyText) + if (client.email) { doc.text(client.email, pageW / 2 + 5, toY); toY += 5 } + if (client.address) { doc.text(client.address, pageW / 2 + 5, toY); toY += 5 } + + y = Math.max(fromY, toY) + 10 + + // --- Table: very dark header, cyan names, alternating dark rows --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Very dark header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows with alternating dark + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) { + y = 20 + setFill(doc, c.background) + doc.rect(0, 0, 210, 297, 'F') + } + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals: cyan 18pt --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 11. VIBRANT — Coral header band, warm tones +// =========================================================================== + +function renderVibrant( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Full-width coral rect --- + setFill(doc, c.headerBg) + doc.rect(0, 0, 210, 45, 'F') + + // White "INVOICE" + biz inside + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.headerText) + doc.text('INVOICE', margin, 22) + + // Biz name right in white + doc.setFontSize(14) + doc.text(biz.name || '', pageW - margin, 18, { align: 'right' }) + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + let bizY = 25 + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + // Invoice meta white inside band + doc.setFontSize(10) + doc.text(`# ${invoice.invoice_number}`, margin, 32) + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, 38) + } + + // --- From / To below band --- + let y = 55 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Light warm table --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 20 + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals: coral 18pt --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 12. CORPORATE — Blue header with info bar below +// =========================================================================== + +function renderCorporate( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Blue header rect --- + setFill(doc, c.headerBg) + doc.rect(0, 0, 210, 45, 'F') + + // White text inside + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.headerText) + doc.text('INVOICE', margin, 22) + + doc.setFontSize(14) + doc.text(biz.name || '', pageW - margin, 18, { align: 'right' }) + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + let bizY = 26 + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + // --- Lighter blue info bar below header --- + setFill(doc, c.secondary) + doc.rect(0, 45, 210, 8, 'F') + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + setText(doc, '#ffffff') + doc.text(`# ${invoice.invoice_number}`, margin, 50) + doc.text(`Date: ${formatDate(invoice.date)}`, margin + 45, 50) + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin + 95, 50) + } + doc.text(`Status: ${invoice.status}`, pageW - margin, 50, { align: 'right' }) + + // --- From / To --- + let y = 63 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: bordered, blue header --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows with thin gray borders + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 20 + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + // Thin borders + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.2) + doc.rect(margin, y, contentW, rowH, 'S') + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals: blue 18pt --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 13. FRESH — Oversized watermark invoice number +// =========================================================================== + +function renderFresh( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Logo left --- + let y = 20 + const logoH = drawLogo(doc, biz.logo, margin, y, 16) + if (logoH > 0) y += logoH + + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.headerText) + doc.text(biz.name || '', margin, y) + y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, margin, y); y += 5 } + if (biz.email) { doc.text(biz.email, margin, y); y += 5 } + if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } + + // --- Large invoice number right side with light blue bg --- + // Light bg rect behind oversized number + setFill(doc, c.tableRowAlt) + doc.rect(pageW - margin - 70, 16, 70, 28, 'F') + // "INVOICE" 14pt above number + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.primary) + doc.text('INVOICE', pageW - margin, 24, { align: 'right' }) + // Oversized number ~32pt + doc.setFontSize(32) + setText(doc, c.secondary) + doc.text(invoice.invoice_number, pageW - margin, 38, { align: 'right' }) + + // Date/due + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`Date: ${formatDate(invoice.date)}`, pageW - margin, 48, { align: 'right' }) + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, 55, { align: 'right' }) + } + + // --- From / To --- + y = Math.max(y, 60) + 4 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: sky blue header, light blue zebra --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) y = 20 + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals: sky blue 18pt --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 14. NATURAL — Warm beige full-page background, terracotta accents +// =========================================================================== + +function renderNatural( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + + // --- Full-page warm beige bg --- + setFill(doc, c.background) + doc.rect(0, 0, 210, 297, 'F') + + // "INVOICE" 28pt terracotta + let y = 28 + doc.setFont('helvetica', 'bold') + doc.setFontSize(28) + setText(doc, c.primary) + doc.text('INVOICE', margin, y) + y += 8 + + // Invoice meta + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, margin, y) + y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) + y += 5 + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, c.primary) + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 5 + } + + // Biz info right in warm brown + let bizY = 22 + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + setText(doc, c.headerText) + doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) + bizY += 6 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } + + y = Math.max(y, bizY) + 6 + + // --- From / To --- + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: terracotta header, warm cream alternating on beige --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Terracotta header + setFill(doc, c.tableHeaderBg) + doc.rect(margin, y, contentW, rowH, 'F') + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Rows + for (let i = 0; i < items.length; i++) { + if (pageBreak(doc, y)) { + y = 20 + setFill(doc, c.background) + doc.rect(0, 0, 210, 297, 'F') + } + if (i % 2 === 1) { + setFill(doc, c.tableRowAlt) + doc.rect(margin, y, contentW, rowH, 'F') + } + y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) + } + + // --- Totals: terracotta 18pt --- + y += 10 + y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// 15. STATEMENT — Total-forward design, hero amount top-right +// =========================================================================== + +function renderStatement( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + biz: BusinessInfo, +): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }) + const margin = 20 + const pageW = 210 + const contentW = pageW - margin * 2 + const c = config.colors + const rose = c.secondary + + // --- "INVOICE" 16pt normal top-left --- + let y = 22 + doc.setFont('helvetica', 'normal') + doc.setFontSize(16) + setText(doc, c.primary) + doc.text('INVOICE', margin, y) + y += 6 + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text(`# ${invoice.invoice_number}`, margin, y) + y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) + y += 5 + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 5 + } + + // Biz info below invoice meta + y += 2 + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, c.bodyText) + doc.text(biz.name || '', margin, y) + y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + if (biz.address) { doc.text(biz.address, margin, y); y += 5 } + if (biz.email) { doc.text(biz.email, margin, y); y += 5 } + if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } + + // --- TOTAL AMOUNT massive top-right --- + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + setText(doc, c.bodyText) + doc.text('TOTAL DUE', pageW - margin, 20, { align: 'right' }) + + doc.setFontSize(28) + setText(doc, rose) + doc.text(formatCurrency(invoice.total), pageW - margin, 32, { align: 'right' }) + + // Due date below total if present + if (invoice.due_date) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + setText(doc, rose) + doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, 40, { align: 'right' }) + } + + // --- From / To below --- + y = Math.max(y, 50) + 4 + const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) + const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) + y = Math.max(fromEndY, toEndY) + 10 + + // --- Table: whitespace-only, no borders, no stripes --- + const layout = tableLayout(margin, contentW) + const rowH = 9 + const padX = 4 + + // Header text only (no bg) + drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) + y += rowH + + // Thin separator below header + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin, y, margin + contentW, y) + + // Rows + for (const item of items) { + if (pageBreak(doc, y)) y = 20 + y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) + } + + // --- Bottom totals: simple summary --- + y += 8 + setDraw(doc, c.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin + contentW - 60, y, margin + contentW, y) + y += 6 + const totalRightX = margin + contentW + doc.setFont('helvetica', 'normal') + doc.setFontSize(10) + setText(doc, c.bodyText) + doc.text('Subtotal', totalRightX - 50, y) + doc.text(formatCurrency(invoice.subtotal), totalRightX, y, { align: 'right' }) + y += 6 + if (invoice.tax_rate > 0) { + doc.text(`Tax (${invoice.tax_rate}%)`, totalRightX - 50, y) + doc.text(formatCurrency(invoice.tax_amount), totalRightX, y, { align: 'right' }) + y += 6 + } + if (invoice.discount > 0) { + doc.text('Discount', totalRightX - 50, y) + doc.text(`-${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) + y += 6 + } + y += 2 + doc.setFont('helvetica', 'bold') + doc.setFontSize(18) + setText(doc, rose) + doc.text('TOTAL', totalRightX - 50, y) + doc.text(formatCurrency(invoice.total), totalRightX, y, { align: 'right' }) + + // --- Notes --- + y += 12 + drawNotes(doc, invoice, margin, contentW, y, c.bodyText) + + return doc +} + + +// =========================================================================== +// Main export +// =========================================================================== export function renderInvoicePdf( config: InvoiceTemplateConfig, @@ -1088,34 +1825,22 @@ export function renderInvoicePdf( items: InvoiceItem[], businessInfo: BusinessInfo, ): jsPDF { - const doc = new jsPDF() - const pageWidth = doc.internal.pageSize.getWidth() // 210 - const pageHeight = doc.internal.pageSize.getHeight() // 297 - const margin = 20 - const contentWidth = pageWidth - margin * 2 - - // Draw page background - if (config.colors.background.toUpperCase() !== '#FFFFFF') { - setFill(doc, config.colors.background) - doc.rect(0, 0, pageWidth, pageHeight, 'F') + switch (config.id) { + case 'clean': return renderClean(config, invoice, client, items, businessInfo) + case 'professional': return renderProfessional(config, invoice, client, items, businessInfo) + case 'bold': return renderBold(config, invoice, client, items, businessInfo) + case 'minimal': return renderMinimal(config, invoice, client, items, businessInfo) + case 'classic': return renderClassic(config, invoice, client, items, businessInfo) + case 'modern': return renderModern(config, invoice, client, items, businessInfo) + case 'elegant': return renderElegant(config, invoice, client, items, businessInfo) + case 'creative': return renderCreative(config, invoice, client, items, businessInfo) + case 'compact': return renderCompact(config, invoice, client, items, businessInfo) + case 'dark': return renderDark(config, invoice, client, items, businessInfo) + case 'vibrant': return renderVibrant(config, invoice, client, items, businessInfo) + case 'corporate': return renderCorporate(config, invoice, client, items, businessInfo) + case 'fresh': return renderFresh(config, invoice, client, items, businessInfo) + case 'natural': return renderNatural(config, invoice, client, items, businessInfo) + case 'statement': return renderStatement(config, invoice, client, items, businessInfo) + default: return renderClean(config, invoice, client, items, businessInfo) } - - // Draw decorative elements - renderDecorative(doc, config, pageWidth, pageHeight) - - // Draw header - let y = renderHeader(doc, config, invoice, client, businessInfo, margin, margin, pageWidth, contentWidth) - - // Draw line items table - y = renderTable(doc, config, items, y, margin, contentWidth, pageWidth, pageHeight) - - // Draw totals - y = renderTotals(doc, config, invoice, y, margin, pageWidth, contentWidth, pageHeight) - - // Draw notes - if (invoice.notes) { - renderNotes(doc, config, invoice.notes, y, margin, contentWidth, pageWidth, pageHeight) - } - - return doc }