ultisuite-client/scripts/emit-authentik-brand.mjs
2026-05-25 13:52:40 +02:00

174 lines
5.4 KiB
JavaScript

/**
* 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 <link rel="icon"> 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)
})