275 lines
7.3 KiB
TypeScript
275 lines
7.3 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas';
|
|
import { join } from 'node:path';
|
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { strictAssert } from '../util/assert';
|
|
|
|
const cwd = __dirname;
|
|
const fontsDir = join(cwd, '..', '..', 'fonts');
|
|
const imagesDir = join(cwd, '..', '..', 'images');
|
|
const trayIconsDir = join(imagesDir, 'tray-icons');
|
|
const trayIconsBaseDir = join(trayIconsDir, 'base');
|
|
const trayIconsAlertsDir = join(trayIconsDir, 'alert');
|
|
|
|
enum TrayIconSize {
|
|
Size16 = '16',
|
|
Size32 = '32',
|
|
Size48 = '48',
|
|
Size256 = '256',
|
|
}
|
|
|
|
type TrayIconValue = number | string | null;
|
|
|
|
type TrayIconImageRequest = Readonly<{
|
|
size: TrayIconSize;
|
|
value: TrayIconValue;
|
|
}>;
|
|
|
|
type TrayIconVariant = {
|
|
size: number;
|
|
maxCount: number;
|
|
badgePadding: number;
|
|
fontSize: number;
|
|
fontWeight: string;
|
|
fontOffsetY: number;
|
|
badgeShadowBlur: number;
|
|
badgeShadowOffsetY: number;
|
|
image: string;
|
|
};
|
|
|
|
GlobalFonts.loadFontsFromDir(fontsDir);
|
|
|
|
const Inter = GlobalFonts.families.find(family => {
|
|
return family.family === 'Inter';
|
|
});
|
|
|
|
strictAssert(Inter != null, `Failed to load fonts from ${fontsDir}`);
|
|
|
|
const Constants = {
|
|
fontFamily: 'Inter',
|
|
badgeColor: 'rgb(244, 67, 54)',
|
|
badgeShadowColor: 'rgba(0, 0, 0, 0.25)',
|
|
};
|
|
|
|
const Variants: Record<TrayIconSize, TrayIconVariant> = {
|
|
[TrayIconSize.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'),
|
|
},
|
|
[TrayIconSize.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'),
|
|
},
|
|
[TrayIconSize.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'),
|
|
},
|
|
[TrayIconSize.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'),
|
|
},
|
|
};
|
|
|
|
function trayIconValueToText(
|
|
value: TrayIconValue,
|
|
variant: TrayIconVariant
|
|
): string {
|
|
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}`);
|
|
}
|
|
|
|
async function generateTrayIconImage(
|
|
request: TrayIconImageRequest
|
|
): Promise<Buffer> {
|
|
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');
|
|
}
|
|
|
|
function range(start: number, end: number): Array<number> {
|
|
const length = end - start + 1;
|
|
return Array.from({ length }, (_, index) => start + index);
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
await rm(trayIconsAlertsDir, { recursive: true });
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const requests: Array<TrayIconImageRequest> = [];
|
|
for (const size of Object.values(TrayIconSize)) {
|
|
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');
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|