/** * Assets branding Authentik (logo + favicon light/dark). * Sortie : ulti-backend/deploy/authentik/branding/ * * pnpm run brand:authentik */ import { existsSync, mkdirSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import sharp from "sharp" const __dirname = dirname(fileURLToPath(import.meta.url)) const root = join(__dirname, "..") const brandDir = join(root, "public/brand") const outDir = join(root, "../ulti-backend/deploy/authentik/branding") function findOriginalPng() { const a = join(brandDir, "ultimail-original.png") const b = join(brandDir, "utlimail-original.png") if (existsSync(a)) return a if (existsSync(b)) return b throw new Error("Missing public/brand/ultimail-original.png — run: pnpm run brand:build") } function headerIconPath() { const p = join(brandDir, "ultimail-header-icon.png") if (!existsSync(p)) { throw new Error("Missing ultimail-header-icon.png — run: pnpm run brand:header-icon") } return p } async function extractWordmarkTextStrip(originalPath) { const meta = await sharp(originalPath).metadata() const W = meta.width ?? 0 const H = meta.height ?? 0 if (!W || !H) throw new Error("Could not read original dimensions") const splitY = Math.min(H - 32, Math.max(80, Math.round(H * 0.62))) const strip = await sharp(originalPath) .extract({ left: 0, top: splitY, width: W, height: H - splitY }) .flatten({ background: "#ffffff" }) .png() .toBuffer() return sharp(strip).trim({ threshold: 12 }).png().toBuffer() } /** Bandeau texte clair sur fond transparent (thème sombre Authentik). */ async function invertTextStripForDark(stripBuf) { const { data, info } = await sharp(stripBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) if (info.channels !== 4) throw new Error("expected RGBA") for (let i = 0; i < data.length; i += 4) { const r = data[i] const g = data[i + 1] const b = data[i + 2] if (r >= 235 && g >= 235 && b >= 235) { data[i + 3] = 0 continue } data[i] = Math.min(255, 255 - r + 40) data[i + 1] = Math.min(255, 255 - g + 40) data[i + 2] = Math.min(255, 255 - b + 40) data[i + 3] = 255 } return sharp(Buffer.from(data), { raw: { width: info.width, height: info.height, channels: 4 }, }) .png() .toBuffer() } async function buildHorizontalWordmark(iconPath, textBuf, { transparent = false } = {}) { const iconH = 120 const iconBuf = await sharp(iconPath) .resize({ height: iconH, fit: "inside" }) .png() .toBuffer() const textResized = await sharp(textBuf) .resize({ height: Math.round(iconH * 0.72), fit: "inside" }) .png() .toBuffer() const iw = (await sharp(iconBuf).metadata()).width ?? 0 const ih = (await sharp(iconBuf).metadata()).height ?? 0 const tw = (await sharp(textResized).metadata()).width ?? 0 const th = (await sharp(textResized).metadata()).height ?? 0 const padX = 24 const gap = 28 const canvasW = iw + tw + padX * 2 + gap const canvasH = Math.max(ih, th) + 16 const bg = transparent ? { r: 0, g: 0, b: 0, alpha: 0 } : { r: 255, g: 255, b: 255, alpha: 1 } return sharp({ create: { width: canvasW, height: canvasH, channels: 4, background: bg, }, }) .composite([ { input: iconBuf, left: padX, top: Math.round((canvasH - ih) / 2) }, { input: textResized, left: padX + iw + gap, top: Math.round((canvasH - th) / 2) }, ]) .png({ compressionLevel: 9 }) .toBuffer() } async function writeFavicon(iconPath, outPath, { bg } = {}) { const size = 32 const iconSize = 26 const iconBuf = await sharp(iconPath) .resize(iconSize, iconSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png() .toBuffer() const background = bg === "light" ? { r: 255, g: 255, b: 255, alpha: 1 } : bg === "dark" ? { r: 24, g: 24, b: 27, alpha: 1 } : { r: 0, g: 0, b: 0, alpha: 0 } await sharp({ create: { width: size, height: size, channels: 4, background, }, }) .composite([{ input: iconBuf, left: Math.round((size - iconSize) / 2), top: Math.round((size - iconSize) / 2) }]) .png({ compressionLevel: 9 }) .toFile(outPath) } async function main() { mkdirSync(outDir, { recursive: true }) const original = findOriginalPng() const headerIcon = headerIconPath() const textStrip = await extractWordmarkTextStrip(original) const lightLogo = await buildHorizontalWordmark(headerIcon, textStrip, { transparent: false }) await sharp(lightLogo).toFile(join(outDir, "ultimail-logo-light.png")) const darkText = await invertTextStripForDark(textStrip) const darkLogo = await buildHorizontalWordmark(headerIcon, darkText, { transparent: true }) await sharp(darkLogo).toFile(join(outDir, "ultimail-logo-dark.png")) await writeFavicon(headerIcon, join(outDir, "ultimail-favicon-light.png"), { bg: "light" }) await writeFavicon(headerIcon, join(outDir, "ultimail-favicon-dark.png"), { bg: "dark" }) // Favicon tab : URL statique (Authentik ne remplace pas %(theme)s dans SSR). await writeFavicon(headerIcon, join(outDir, "ultimail-favicon.png")) console.log("Wrote Authentik branding to", outDir) console.log(" ultimail-logo-light.png / ultimail-logo-dark.png") console.log(" ultimail-favicon.png (+ light/dark variants)") } main().catch((e) => { console.error(e) process.exit(1) })