// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { fabric } from 'fabric'; import type { DonationReceipt } from '../types/Donations'; import type { LocalizerType } from '../types/Util'; import { strictAssert } from './assert'; import { getDateTimeFormatter } from './formatTimestamp'; import { isStagingServer } from './isStagingServer'; const SCALING_FACTOR = 4.17; // Color constants matching SCSS variables const COLORS = { WHITE: '#ffffff', GRAY_20: '#c6c6c6', GRAY_45: '#848484', GRAY_60: '#5e5e5e', GRAY_90: '#1b1b1b', GRAY_95: '#121212', } as const; /** * Helper function to scale font sizes, heights, and letter spacing for the receipt * @param params - Object containing original values to scale * @param params.fontSize - Original font size in pixels * @param params.height - Optional original height/margin/padding in pixels * @param params.letterSpacing - Optional original letter spacing in pixels * @returns Scaled values for use in FabricJS */ function scaleValues(params: { fontSize: number; height?: number; letterSpacing?: number; }): { fontSize: number; height?: number; charSpacing?: number; } { const result: { fontSize: number; height?: number; charSpacing?: number; } = { fontSize: params.fontSize * SCALING_FACTOR, }; if (params.height !== undefined) { result.height = params.height * SCALING_FACTOR; } if (params.letterSpacing !== undefined) { // FabricJS charSpacing is in thousandths of em units // Formula: (letterSpacingPx * 1000) / fontSizePx // This converts pixel-based letter spacing to em-based units // For example: -0.13px letter spacing on 12px font = // (-0.13 * 1000) / 12 = -10.83 thousandths of em result.charSpacing = (params.letterSpacing * 1000) / params.fontSize; } return result; } const SIGNAL_LOGO_SVG = ` `; export async function generateDonationReceiptBlob( receipt: DonationReceipt, i18n: LocalizerType ): Promise { const width = 2550; const height = 3300; const canvas = new fabric.StaticCanvas(null, { width, height, backgroundColor: COLORS.WHITE, }); const fontFamily = 'Inter'; const paddingTop = 70 * SCALING_FACTOR; const paddingX = 66 * SCALING_FACTOR; const contentWidth = width - paddingX * 2; let currentY = paddingTop; // Create an image from the SVG const logo = await new Promise((resolve, reject) => { const logoDataUrl = `data:image/svg+xml;base64,${btoa(SIGNAL_LOGO_SVG)}`; fabric.Image.fromURL(logoDataUrl, fabricImg => { if (!fabricImg) { reject(new Error('Failed to load logo')); return; } // Position the logo fabricImg.set({ left: paddingX, top: currentY, }); resolve(fabricImg); }); }); canvas.add(logo); const dateFormatter = getDateTimeFormatter({ month: 'short', day: '2-digit', year: 'numeric', }); const dateStr = dateFormatter.format(new Date()); const dateText = new fabric.Text(dateStr, { left: width - paddingX, top: currentY + (logo.height ?? 0), fontFamily, fill: COLORS.GRAY_60, originX: 'right', originY: 'bottom', ...scaleValues({ fontSize: 12, letterSpacing: -0.03 }), }); canvas.add(dateText); currentY += (logo.height ?? 0) + 16 * SCALING_FACTOR; const divider1 = new fabric.Rect({ left: paddingX, top: currentY, width: contentWidth, height: 1 * SCALING_FACTOR, fill: COLORS.GRAY_20, }); canvas.add(divider1); strictAssert(divider1.height != null, 'Divider1 height must be defined'); currentY += divider1.height; currentY += 167; const title = new fabric.Text(i18n('icu:DonationReceipt__title'), { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_90, ...scaleValues({ fontSize: 20, letterSpacing: -0.34 }), }); canvas.add(title); strictAssert(title.height != null, 'Title height must be defined'); currentY += title.height + 29 * SCALING_FACTOR; // Amount section const amountLabel = new fabric.Text( i18n('icu:DonationReceipt__amount-label'), { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_90, ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), } ); canvas.add(amountLabel); // Format currency const preferredSystemLocales = window.SignalContext.getPreferredSystemLocales(); const localeOverride = window.SignalContext.getLocaleOverride(); const locales = localeOverride != null ? [localeOverride] : preferredSystemLocales; const formatter = new Intl.NumberFormat(locales, { style: 'currency', currency: receipt.currencyType, }); const amountStr = formatter.format(receipt.paymentAmount / 100); const amountValue = new fabric.Text(amountStr, { left: width - paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_90, originX: 'right', ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), }); canvas.add(amountValue); strictAssert( amountLabel.height != null, 'Amount label height must be defined' ); strictAssert( amountValue.height != null, 'Amount value height must be defined' ); currentY += Math.max(amountLabel.height, amountValue.height) + 25 * SCALING_FACTOR; const boldDivider = new fabric.Rect({ left: paddingX, top: currentY, width: contentWidth, height: 1 * SCALING_FACTOR, fill: COLORS.GRAY_90, }); canvas.add(boldDivider); strictAssert( boldDivider.height != null, 'Bold divider height must be defined' ); currentY += boldDivider.height; // Details section (margin-top: 50px) currentY += 12 * SCALING_FACTOR; // Detail row 1 - Type (padding: 50px 0) currentY += 12 * SCALING_FACTOR; const typeLabel = new fabric.Text(i18n('icu:DonationReceipt__type-label'), { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_95, ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), }); canvas.add(typeLabel); strictAssert(typeLabel.height != null, 'Type label height must be defined'); currentY += 4 * SCALING_FACTOR + typeLabel.height; // margin-bottom + actual height const typeValue = new fabric.Text( i18n('icu:DonationReceipt__type-value--one-time'), { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_45, ...scaleValues({ fontSize: 12, letterSpacing: -0.08 }), } ); canvas.add(typeValue); strictAssert(typeValue.height != null, 'Type value height must be defined'); currentY += typeValue.height + 50; // actual height + bottom padding const rowDivider = new fabric.Rect({ left: paddingX, top: currentY, width: contentWidth, height: 1 * SCALING_FACTOR, fill: COLORS.GRAY_20, }); canvas.add(rowDivider); strictAssert(rowDivider.height != null, 'Row divider height must be defined'); currentY += rowDivider.height; // Detail row 2 - Date Paid currentY += 12 * SCALING_FACTOR; const dateLabel = new fabric.Text( i18n('icu:DonationReceipt__date-paid-label'), { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_95, ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), } ); canvas.add(dateLabel); strictAssert(dateLabel.height != null, 'Date label height must be defined'); currentY += 4 * SCALING_FACTOR + dateLabel.height; const paymentDateFormatter = getDateTimeFormatter({ month: 'short', day: 'numeric', year: 'numeric', }); const paymentDate = paymentDateFormatter.format(new Date(receipt.timestamp)); const dateValue = new fabric.Text(paymentDate, { left: paddingX, top: currentY, fontFamily, fill: COLORS.GRAY_45, ...scaleValues({ fontSize: 12, letterSpacing: -0.08 }), }); canvas.add(dateValue); strictAssert(dateValue.height != null, 'Date value height must be defined'); currentY += dateValue.height + 50; currentY += 10 * SCALING_FACTOR; const footerText = i18n('icu:DonationReceipt__footer-text'); const footer = new fabric.Textbox(footerText, { left: paddingX, top: currentY, width: contentWidth, fontFamily, fill: COLORS.GRAY_60, lineHeight: 1.45, ...scaleValues({ fontSize: 11 }), }); canvas.add(footer); // Add staging indicator if in staging environment if (isStagingServer()) { strictAssert(footer.height != null, 'Footer height must be defined'); currentY += footer.height + 100 * SCALING_FACTOR; const stagingText = new fabric.Text( 'NOT A REAL RECEIPT / FOR TESTING ONLY', { left: width / 2, top: currentY, fontFamily, fontSize: 24 * SCALING_FACTOR, fontWeight: 'bold', fill: '#7C3AED', originX: 'center', textAlign: 'center', } ); canvas.add(stagingText); } canvas.renderAll(); // Convert canvas to PNG blob // First, get the canvas as a data URL (base64 encoded string) const dataURL = canvas.toDataURL({ format: 'png', multiplier: 1, }); // Extract the base64 encoded data from the data URL // Data URL format: "data:image/png;base64,iVBORw0KGgoAAAANS..." const base64Data = dataURL.split(',')[1]; // Decode the base64 string to binary data const binaryString = atob(base64Data); // Convert the binary string directly to a typed array const byteArray = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i += 1) { byteArray[i] = binaryString.charCodeAt(i); } const blob = new Blob([byteArray], { type: 'image/png' }); return blob; }