/** * Mandala Watercolor Invoice Template * * Design: Unconventional, modern invoice with mandala theme * - Full-bleed deep teal background with watercolor mandala * - White/mint text for high contrast * - Total as hero element top-right * - Minimal, airy spacing */ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { PDF, rgb, StandardFonts } from "@libpdf/core"; import type { InvoicePDFData, InvoiceTemplate } from "../types"; import { fonts, formatCurrencyWhole, formatDate, padInvoiceNumber, rightAlignX, truncateToWidth, wrapToWidth, } from "./types"; const __dirname = dirname(fileURLToPath(import.meta.url)); // ===== DESIGN TOKENS ===== const colors = { // Background - deep teal bg: rgb(0.086, 0.373, 0.373), // #165F5F // Text colors for dark background white: rgb(1, 1, 1), cream: rgb(0.96, 0.94, 0.9), // Warm cream - softer than pure white mint: rgb(0.78, 0.88, 0.85), // Soft sage mint gold: rgb(0.88, 0.78, 0.45), // Softer, warmer gold // Panel/overlay colors panel: rgb(0.04, 0.2, 0.2), // Darker teal for panels }; // Typography scale - softer, more organic const type = { hero: 48, // Total amount label: 8, // Section labels - delicate h1: 11, // Line item names body: 9.5, // Body text small: 8.5, // Details tiny: 7.5, // Fine print mono: 8.5, // Monospace data }; // Spacing scale const space = { xs: 4, sm: 10, md: 18, lg: 28, xl: 44, xxl: 64, }; // Layout const pageWidth = 612; const pageHeight = 792; const margin = 48; const rightEdge = pageWidth - margin; async function render(data: InvoicePDFData): Promise { const pdf = PDF.create(); const page = pdf.addPage({ width: pageWidth, height: pageHeight }); const panelPad = space.md; const cornerRadius = 12; // ===== FULL BLEED WATERCOLOR MANDALA BACKGROUND ===== // Walk from the module dir up to the package root, then into assets/ const mandalaPath = join( __dirname, "..", "..", "..", "assets", "mandala-bg.png", ); let bgLoaded = false; try { const mandalaBytes = await Bun.file(mandalaPath).arrayBuffer(); const mandalaImage = await pdf.embedPng(new Uint8Array(mandalaBytes)); // Scale to cover page (crop if needed to fill) const imgAspect = mandalaImage.width / mandalaImage.height; const pageAspect = pageWidth / pageHeight; let drawWidth: number; let drawHeight: number; let drawX: number; let drawY: number; if (imgAspect > pageAspect) { // Image is wider - fit to height, crop sides drawHeight = pageHeight; drawWidth = drawHeight * imgAspect; drawX = (pageWidth - drawWidth) / 2; drawY = 0; } else { // Image is taller - fit to width, crop top/bottom drawWidth = pageWidth; drawHeight = drawWidth / imgAspect; drawX = 0; drawY = (pageHeight - drawHeight) / 2; } // Draw at full opacity - the image IS the background page.drawImage(mandalaImage, { x: drawX, y: drawY, width: drawWidth, height: drawHeight, }); bgLoaded = true; } catch { // Asset not found — cannot render this template without its background } if (!bgLoaded) { throw new Error( `Mandala background image not found at ${mandalaPath}. ` + "The mandala template requires the mandala-bg.png asset.", ); } // ===== HEADER ===== let y = pageHeight - margin; // Company name - top left (delicate, italic for organic feel) page.drawText(data.from.companyName, { x: margin, y, size: type.label, color: colors.mint, font: StandardFonts.TimesItalic, }); // Address below (if provided) if (data.from.address) { page.drawText(data.from.address, { x: margin, y: y - type.label - 3, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); } // ===== HERO TOTAL - TOP RIGHT ===== const totalStr = formatCurrencyWhole(data.total); page.drawText(totalStr, { x: rightAlignX(totalStr, rightEdge, type.hero, fonts.timesBold), y: y - 4, size: type.hero, color: colors.gold, font: StandardFonts.TimesBold, }); // Due date under total if (data.dueDate) { const dueText = `Due ${formatDate(data.dueDate)}`; page.drawText(dueText, { x: rightAlignX(dueText, rightEdge, type.small, fonts.timesRoman), y: y - type.hero - space.xs, size: type.small, color: colors.cream, font: StandardFonts.TimesItalic, }); } // ===== META PANEL ===== const metaPanelY = pageHeight - margin - space.xxl - space.lg; const metaPanelHeight = 50; // Semi-transparent rounded panel page.drawRectangle({ x: margin, y: metaPanelY - metaPanelHeight + panelPad, width: rightEdge - margin, height: metaPanelHeight, color: colors.panel, opacity: 0.35, cornerRadius, }); y = metaPanelY; // Three columns inside panel const col1 = margin + panelPad; const col2 = margin + 170; const col3 = margin + 320; // Invoice page.drawText("Invoice", { x: col1, y, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); page.drawText(padInvoiceNumber(data.invoiceNumber), { x: col1, y: y - space.sm - 2, size: type.mono, color: colors.cream, font: StandardFonts.Courier, }); // Date page.drawText("Date", { x: col2, y, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); page.drawText(formatDate(data.invoiceDate), { x: col2, y: y - space.sm - 2, size: type.mono, color: colors.cream, font: StandardFonts.Courier, }); // Client page.drawText("Client", { x: col3, y, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); page.drawText(data.to.name, { x: col3, y: y - space.sm - 2, size: type.body, color: colors.cream, font: StandardFonts.TimesRoman, }); // ===== LINE ITEMS PANEL ===== const itemsPanelY = metaPanelY - metaPanelHeight - space.lg; const hasCredits = data.credits.length > 0; const hasWorkBreakdown = data.workBreakdown && data.workBreakdown.length > 0; const itemPadX = margin + panelPad; const amountX = rightEdge - panelPad; const priceX = rightEdge - panelPad - 80; const qtyX = rightEdge - panelPad - 140; const numberColumns = [ { label: "Qty", x: qtyX, value: (i: (typeof data.lineItems)[0]) => String(i.quantity), }, { label: "Price", x: priceX, value: (i: (typeof data.lineItems)[0]) => formatCurrencyWhole(i.rate), }, { label: "Amount", x: amountX, value: (i: (typeof data.lineItems)[0]) => formatCurrencyWhole(i.amount), }, ]; const descMaxWidth = qtyX - itemPadX - 24; const descLineHeight = type.h1 * 1.25; const labelRowGap = space.md; const wrappedDescriptions = data.lineItems.map((item) => wrapToWidth(item.description, descMaxWidth, type.h1, fonts.timesRoman), ); const extraDescLines = wrappedDescriptions.reduce( (sum, lines) => sum + lines.length - 1, 0, ); let itemsPanelHeight = 55 + labelRowGap; if (data.lineItems.length > 1) itemsPanelHeight += (data.lineItems.length - 1) * 30; itemsPanelHeight += extraDescLines * descLineHeight; if (hasWorkBreakdown) itemsPanelHeight += (data.workBreakdown?.length ?? 0) * 12 + space.sm; if (hasCredits) itemsPanelHeight += data.credits.length * 45; page.drawRectangle({ x: margin, y: itemsPanelY - itemsPanelHeight + panelPad, width: rightEdge - margin, height: itemsPanelHeight, color: colors.panel, opacity: 0.35, cornerRadius, }); // Column labels (matches metadata-panel label treatment) for (const col of numberColumns) { page.drawText(col.label, { x: rightAlignX(col.label, col.x, type.label, fonts.timesItalic), y: itemsPanelY, size: type.label, color: colors.mint, font: StandardFonts.TimesItalic, }); } y = itemsPanelY - labelRowGap; for (let i = 0; i < data.lineItems.length; i++) { const item = data.lineItems[i]; if (i > 0) y -= space.lg + space.xs; const itemTopY = y; const descLines = wrappedDescriptions[i]; descLines.forEach((line, li) => { page.drawText(line, { x: itemPadX, y: itemTopY - li * descLineHeight, size: type.h1, color: colors.cream, font: StandardFonts.TimesRoman, }); }); y = itemTopY - (descLines.length - 1) * descLineHeight; if (item.projectName) { page.drawText(item.projectName, { x: itemPadX, y: y - space.sm - 2, size: type.small, color: colors.mint, font: StandardFonts.TimesItalic, }); } for (const col of numberColumns) { const value = col.value(item); page.drawText(value, { x: rightAlignX(value, col.x, type.mono, fonts.courier), y: itemTopY, size: type.mono, color: colors.cream, font: StandardFonts.Courier, }); } if (i === 0 && hasWorkBreakdown) { y -= space.sm; const projectColX = itemPadX + 10; const commitsColX = margin + 200; for (const wb of data.workBreakdown ?? []) { y -= 12; page.drawText(wb.project, { x: projectColX, y, size: type.tiny, color: colors.cream, font: StandardFonts.TimesRoman, }); const commitStr = `${wb.commits} commits`; page.drawText(commitStr, { x: commitsColX, y, size: type.tiny, color: colors.mint, font: StandardFonts.Courier, }); if (wb.highlights) { page.drawText(wb.highlights, { x: commitsColX + 80, y, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); } } } } // Credit rows if applicable for (const credit of data.credits) { y -= space.lg; page.drawText("Credit", { x: itemPadX, y, size: type.h1, color: colors.mint, font: StandardFonts.TimesItalic, }); const creditDescMaxWidth = amountX - itemPadX - 80; const truncatedCreditDesc = truncateToWidth( credit.description, creditDescMaxWidth, type.small, fonts.timesItalic, ); page.drawText(truncatedCreditDesc, { x: itemPadX, y: y - space.sm - 2, size: type.small, color: colors.mint, font: StandardFonts.TimesItalic, }); const creditStr = `-${formatCurrencyWhole(credit.amount)}`; page.drawText(creditStr, { x: rightAlignX(creditStr, amountX, type.mono, fonts.courier), y, size: type.mono, color: colors.gold, font: StandardFonts.Courier, }); } // ===== PAYMENT PANEL ===== if (data.paymentMethods && data.paymentMethods.length > 0) { const paymentPanelY = margin + space.xxl + space.xl + space.lg + space.md; const paymentPanelHeight = 105; page.drawRectangle({ x: margin, y: paymentPanelY - paymentPanelHeight, width: rightEdge - margin, height: paymentPanelHeight, color: colors.panel, opacity: 0.35, cornerRadius, }); y = paymentPanelY - panelPad; page.drawText("Payment", { x: itemPadX, y, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); y -= space.md; // Render payment methods side by side (up to 2 columns) const methods = data.paymentMethods; const colWidth = (rightEdge - margin - panelPad * 2) / Math.min(methods.length, 2); for (let i = 0; i < methods.length; i++) { const method = methods[i]; const colIndex = i % 2; const baseX = itemPadX + colIndex * colWidth; // Method label page.drawText(method.label, { x: baseX, y, size: type.body, color: colors.cream, font: StandardFonts.TimesRoman, }); // Detail lines let detailY = y - space.sm; const detailEntries = Object.entries(method.details); for (const [key, value] of detailEntries) { const detailText = key ? `${key}: ${value}` : value; // Use smaller font for long values (like crypto addresses) const fontSize = detailText.length > 50 ? 6.5 : type.tiny; page.drawText(detailText, { x: baseX, y: detailY, size: fontSize, color: colors.mint, font: key ? StandardFonts.TimesItalic : StandardFonts.Courier, }); detailY -= fontSize + 2; } } } // ===== FOOTER ===== const footerText = data.notes || "Thank you for your business"; page.drawText(footerText, { x: margin, y: margin - space.sm, size: type.tiny, color: colors.mint, font: StandardFonts.TimesItalic, }); // Save const pdfBytes = await pdf.save(); return pdfBytes; } export const mandalaTemplate: InvoiceTemplate = { name: "mandala", description: "Watercolor mandala design with deep teal background, gold hero total, and organic typography", render, };