import { createCanvas, loadImage } from 'canvas'; import type { CanvasRenderingContext2D, Image } from 'canvas'; import { Router } from 'express'; import SQL from 'sql-template-strings'; import type { Translations } from '../../locale/translations.ts'; import { buildPronounUsage, parsePronounGroups, parsePronouns } from '../../src/buildPronoun.ts'; import { PronounLibrary } from '../../src/classes.ts'; import type { PronounUsage } from '../../src/classes.ts'; import { handleErrorAsync } from '../../src/helpers.ts'; import { Translator } from '../../src/translator.ts'; import { loadTsv } from '../../src/tsv.ts'; import type { User } from '../../src/user.ts'; import avatar from '../avatar.ts'; import { loadSuml, loadSumlFromBase } from '../loader.ts'; import { registerLocaleFont } from '../localeFont.ts'; import { rootDir } from '../paths.ts'; import type { Database } from '~/server/db.ts'; const translations = loadSuml('translations') as Translations; const baseTranslations = loadSumlFromBase('locale/_base/translations') as Translations; const translator = new Translator(translations, baseTranslations, global.config); const pronouns = parsePronouns(global.config, loadTsv(`${rootDir}/data/pronouns/pronouns.tsv`)); const pronounGroups = parsePronounGroups(loadTsv(`${rootDir}/data/pronouns/pronounGroups.tsv`)); const pronounLibrary = new PronounLibrary(global.config, pronounGroups, pronouns); const drawCircle = (context: CanvasRenderingContext2D, image: Image, x: number, y: number, size: number): void => { context.save(); context.beginPath(); context.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2, true); context.closePath(); context.clip(); context.drawImage(image, x, y, size, size); context.beginPath(); context.arc(x, y, size / 2, 0, Math.PI * 2, true); context.clip(); context.closePath(); context.restore(); }; const router = Router(); const width = 1200; const height = 600; const mime = 'image/png'; const imageSize = 200; const leftRatio = 4; const getBannerKey = defineCachedFunction(async (path: string, db: Database) => { const fontName = registerLocaleFont('fontHeadings', ['regular', 'bold']); const canvas = createCanvas(width, height); const context = canvas.getContext('2d'); const bg = await loadImage('public/bg.png'); context.drawImage(bg, 0, 0, width, height); let key = undefined; if (path === 'zaimki') { key = await drawDefault(context, fontName); } if (!key && path.startsWith('@')) { const user = await db.get>(SQL`SELECT id, username, email, avatarSource FROM users WHERE username=${path.substring(1)}`); if (user) { key = await drawProfileBanner(context, fontName, user, db); } } if (!key) { const usage = buildPronounUsage(pronounLibrary, path, config, translator); if (usage !== null) { key = await drawPronounUsage(context, fontName, usage); } } if (!key) { key = await drawDefault(context, fontName); } await useStorage('data').setItemRaw(`banner:${key}`, canvas.toBuffer(mime)); return key; }, { name: 'banner', getKey: (path) => path, maxAge: 24 * 60 * 60, }); const drawDefault = async (context: CanvasRenderingContext2D, fontName: string) => { const leftRatio = 5; const logo = await loadImage('public/logo/logo.svg'); context.drawImage(logo, width / leftRatio - imageSize / 2, height / 2 - imageSize / 2, imageSize, imageSize); context.font = `regular ${translations.title.length < 10 ? 120 : translations.title.length < 14 ? 80 : 72}pt '${fontName}'`; context.fillText(translations.title, width / leftRatio + imageSize / 1.5, height / 2 + (translations.title.length < 10 ? 48 : translations.title.length < 14 ? 36 : 24)); return 'default.png'; }; const drawPronounUsage = async (context: CanvasRenderingContext2D, fontName: string, usage: PronounUsage) => { const logo = await loadImage('public/logo/logo.svg'); context.drawImage(logo, width / leftRatio - imageSize / 2, height / 2 - imageSize / 2, imageSize, imageSize); context.font = `regular 48pt '${fontName}'`; context.fillText(`${translations.pronouns.intro}:`, width / leftRatio + imageSize / 1.5, height / 2 - 36); context.font = `bold ${usage.short.options.length <= 2 ? '70' : '36'}pt '${fontName}'`; context.fillText( usage.short.options.map((o) => o.replace(/ ?\[[^\]]+] ?/g, '').trim()).join('\n'), width / leftRatio + imageSize / 1.5, height / 2 + (usage.short.options.length <= 2 ? 72 : 24), ); return `pronoun:${usage.short.options.join('&').replaceAll(/[/:]/g, '-')}.png`; }; const drawProfileBanner = async ( context: CanvasRenderingContext2D, fontName: string, user: Pick, db: Database, ) => { const logoPrimary = await loadImage('public/logo/logo-primary.svg'); try { const avatarImage = await loadImage(await avatar(db, user)); drawCircle(context, avatarImage, width / leftRatio - imageSize / 2, height / 2 - imageSize / 2, imageSize); } catch {} context.font = `regular 48pt '${fontName}'`; context.fillText(`@${user.username}`, width / leftRatio + imageSize, height / 2); context.font = `regular 24pt '${fontName}'`; context.fillStyle = '#C71585'; const logoSize = 24 * 1.25; context.drawImage(logoPrimary, width / leftRatio + imageSize, height / 2 + logoSize - 8, logoSize, logoSize); context.fillText(translations.title, width / leftRatio + imageSize + 36, height / 2 + 48); return `user:${user.username}.png`; }; router.get('/banner/:pronounName*.png', handleErrorAsync(async (req, res) => { const path = (req.params.pronounName + req.params[0]).replace(/(&)+$/, ''); const key = await getBannerKey(path, req.db); const banner = await useStorage('data').getItemRaw(`banner:${key}`); return res.set('content-type', mime).send(banner); })); export default router;