// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // @ts-check import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; import { join } from 'node:path'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { assert } from './utils/assert.mjs'; const baseDir = join(import.meta.dirname, '..'); const fontsDir = join(baseDir, 'fonts'); const imagesDir = join(baseDir, 'images'); const trayIconsDir = join(imagesDir, 'tray-icons'); const trayIconsBaseDir = join(trayIconsDir, 'base'); const trayIconsAlertsDir = join(trayIconsDir, 'alert'); const TrayIconSizes = /** @type {const} */ ({ Size16: '16', Size32: '32', Size48: '48', Size256: '256', }); /** * @typedef {typeof TrayIconSizes[keyof typeof TrayIconSizes]} TrayIconSize */ /** * @typedef {number | string | null} TrayIconValue */ /** * @typedef {Readonly<{ * size: TrayIconSize; * value: TrayIconValue; * }>} TrayIconImageRequest */ /** * @typedef {{ * size: number; * maxCount: number; * badgePadding: number; * fontSize: number; * fontWeight: string; * fontOffsetY: number; * badgeShadowBlur: number; * badgeShadowOffsetY: number; * image: string; * }} TrayIconVariant */ GlobalFonts.loadFontsFromDir(fontsDir); const Inter = GlobalFonts.families.find(family => { return family.family === 'Inter'; }); assert(Inter != null, `Failed to load fonts from ${fontsDir}`); const Constants = { fontFamily: 'Inter', badgeColor: 'rgb(244, 67, 54)', badgeShadowColor: 'rgba(0, 0, 0, 0.25)', }; /** @type {Record} */ const Variants = { [TrayIconSizes.Size16]: { size: 16, maxCount: 9, badgePadding: 2, fontSize: 8, fontWeight: '500', fontOffsetY: 0, badgeShadowBlur: 0, badgeShadowOffsetY: 0, image: join(trayIconsBaseDir, 'signal-tray-icon-16x16-base.png'), }, [TrayIconSizes.Size32]: { size: 32, maxCount: 9, badgePadding: 4, fontSize: 12, fontWeight: '500', fontOffsetY: 0, badgeShadowBlur: 1, badgeShadowOffsetY: 1, image: join(trayIconsBaseDir, 'signal-tray-icon-32x32-base.png'), }, [TrayIconSizes.Size48]: { size: 48, maxCount: 9, badgePadding: 6, fontSize: 16, fontWeight: '500', fontOffsetY: -1, badgeShadowBlur: 1, badgeShadowOffsetY: 1, image: join(trayIconsBaseDir, 'signal-tray-icon-48x48-base.png'), }, [TrayIconSizes.Size256]: { size: 256, maxCount: 9, fontSize: 72, fontWeight: '600', fontOffsetY: 0, badgePadding: 32, badgeShadowBlur: 8, badgeShadowOffsetY: 8, image: join(trayIconsBaseDir, 'signal-tray-icon-256x256-base.png'), }, }; /** * @param {TrayIconValue} value * @param {TrayIconVariant} variant * @returns {string} */ function trayIconValueToText(value, variant) { if (value == null) { return ''; } if (typeof value === 'string') { return value.trim(); } if (typeof value === 'number') { if (!Number.isSafeInteger(value) || value < 0) { throw new RangeError(`Unread count must be positive integer ${value}`); } if (value === 0) { return ''; } if (value > variant.maxCount) { return `${variant.maxCount}+`; } return `${value}`; } throw new TypeError(`Invalid value ${value}`); } /** * @param {TrayIconImageRequest} request * @returns {Promise>} */ async function generateTrayIconImage(request) { const variant = Variants[request.size]; if (variant == null) { throw new TypeError(`Invalid variant size (${request.size})`); } const text = trayIconValueToText(request.value, variant); const image = await loadImage(variant.image); const canvas = createCanvas(variant.size, variant.size); const context = canvas.getContext('2d'); if (context == null) { throw new Error('Failed to create 2d canvas context'); } context.imageSmoothingEnabled = false; context.imageSmoothingQuality = 'high'; context.drawImage(image, 0, 0, variant.size, variant.size); if (text !== '') { // Decrements by 1 until the badge fits within the canvas. let currentFontSize = variant.fontSize; while (currentFontSize > 4) { const font = `${variant.fontWeight} ${currentFontSize}px ${Constants.fontFamily}`; context.font = font; context.textAlign = 'center'; context.textBaseline = 'middle'; context.textRendering = 'optimizeLegibility'; context.fontKerning = 'normal'; // All font settings should be set before now and should not change. const capMetrics = context.measureText('X'); const textMetrics = context.measureText(text); const textWidth = Math.ceil( textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft ); const textHeight = Math.ceil( capMetrics.actualBoundingBoxAscent + capMetrics.actualBoundingBoxDescent ); const boxHeight = textHeight + variant.badgePadding * 2; const boxWidth = Math.max( boxHeight, // Ensures the badge is a circle textWidth + variant.badgePadding * 2 ); // Needed to avoid cutting off the shadow blur const boxMargin = variant.badgeShadowBlur; const boxWidthWithMargins = boxWidth + boxMargin * 2; if (boxWidthWithMargins > variant.size) { currentFontSize -= 1; continue; } const boxX = variant.size - boxWidth - boxMargin; // right aligned const boxY = boxMargin; const boxMidX = boxX + boxWidth / 2; const boxMidY = boxY + boxHeight / 2; const boxRadius = Math.ceil(boxHeight / 2); context.save(); context.beginPath(); context.roundRect(boxX, boxY, boxWidth, boxHeight, boxRadius); context.fillStyle = Constants.badgeColor; if (variant.badgeShadowBlur !== 0 || variant.badgeShadowOffsetY !== 0) { context.shadowBlur = variant.badgeShadowBlur; context.shadowOffsetX = 0; context.shadowOffsetY = variant.badgeShadowOffsetY; context.shadowColor = Constants.badgeShadowColor; } context.fill(); context.restore(); context.fillStyle = 'white'; context.fillText(text, boxMidX, boxMidY + variant.fontOffsetY); break; } if (currentFontSize <= 4) { throw new Error( `Badge text is too large for canvas size ${variant.size} (${text})` ); } } return canvas.toBuffer('image/png'); } /** * @param {number} start * @param {number} end * @returns {Array} */ function range(start, end) { const length = end - start + 1; return Array.from({ length }, (_, index) => start + index); } try { await rm(trayIconsAlertsDir, { recursive: true }); } catch (error) { if (error.code !== 'ENOENT') { throw error; } } /** @type {Array} */ const requests = []; for (const size of Object.values(TrayIconSizes)) { const variant = Variants[size]; const { maxCount } = variant; const values = range(1, maxCount + 1); for (const value of values) { requests.push({ size, value }); } } await Promise.all( requests.map(async ({ size, value }) => { const variant = Variants[size]; const text = trayIconValueToText(value, variant); const fileDir = join(trayIconsAlertsDir); const fileName = `signal-tray-icon-${size}x${size}-alert-${text}.png`; const filePath = join(fileDir, fileName); const fileContents = await generateTrayIconImage({ size, value }); console.log(`Writing "${fileName}"`); await mkdir(fileDir, { recursive: true }); await writeFile(filePath, fileContents); }) ); console.log('Done');