// 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;
}