ultisuite-client/scripts/emit-ultimail-header-icon.mjs
2026-05-15 17:40:17 +02:00

233 lines
7.1 KiB
JavaScript

/**
* Extrait le pictogramme depuis ultimail-original.png :
* - trim alpha (autocrop), carré transparent + padding symétrique, puis export
* - renfort S/L sur violet coin haut-droit (fonds sombres)
* - public/brand/ultimail-header-icon.png (288px)
* - app/icon.png (32), app/apple-icon.png (180)
*
* pnpm run brand:header-icon
*/
import { existsSync, copyFileSync, 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 appDir = join(root, "app")
/** Fond UI (--app-canvas) + blanc autour du master (strict : évite de manger le bas du picto gris clair). */
function isBackgroundPixel(r, g, b) {
if (r >= 249 && g >= 249 && b >= 249) return true
if (Math.abs(r - 250) <= 5 && Math.abs(g - 251) <= 5 && Math.abs(b - 252) <= 5) return true
const avg = (r + g + b) / 3
const spread = Math.max(r, g, b) - Math.min(r, g, b)
return spread < 10 && avg > 246
}
/**
* @param {Buffer} rgbaRaw
* @param {number} w
* @param {number} h
*/
function knockOutBackground(rgbaRaw, w, h) {
const out = Buffer.from(rgbaRaw)
const stride = 4
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * stride
const r = out[i]
const g = out[i + 1]
const b = out[i + 2]
if (isBackgroundPixel(r, g, b)) out[i + 3] = 0
}
}
return out
}
function rgbToHsl(r255, g255, b255) {
const r = r255 / 255
const g = g255 / 255
const b = b255 / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
let h = 0
let s = 0
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
default:
h = ((r - g) / d + 4) / 6
break
}
}
return { h: h * 360, s, l }
}
function hslToRgb(hDeg, s, l) {
let h = hDeg / 360
let r
let g
let b
if (s === 0) {
r = g = b = l
} else {
const hue2rgb = (p, q, t) => {
let tt = t
if (tt < 0) tt += 1
if (tt > 1) tt -= 1
if (tt < 1 / 6) return p + (q - p) * 6 * tt
if (tt < 1 / 2) return q
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}
return {
r: Math.round(Math.min(255, Math.max(0, r * 255))),
g: Math.round(Math.min(255, Math.max(0, g * 255))),
b: Math.round(Math.min(255, Math.max(0, b * 255))),
}
}
/**
* Accentue le coin haut-droit violet (lisibilité sur fond sombre).
* @param {Buffer} rgbaRaw
* @param {number} w
* @param {number} h
*/
function boostPurpleTopRight(rgbaRaw, w, h) {
const stride = 4
const xMin = Math.floor(w * 0.44)
const yMax = Math.floor(h * 0.44)
for (let y = 0; y <= yMax; y++) {
for (let x = xMin; x < w; x++) {
const i = (y * w + x) * stride
if (rgbaRaw[i + 3] < 28) continue
const r = rgbaRaw[i]
const g = rgbaRaw[i + 1]
const b = rgbaRaw[i + 2]
const { h: hue, s, l } = rgbToHsl(r, g, b)
if (hue < 252 || hue > 322 || s < 0.05) continue
const s2 = Math.min(1, s * 1.42 + 0.12)
const l2 = Math.min(0.97, l * 1.22 + 0.09)
const { r: r2, g: g2, b: b2 } = hslToRgb(hue, s2, l2)
rgbaRaw[i] = r2
rgbaRaw[i + 1] = g2
rgbaRaw[i + 2] = b2
}
}
}
function findSourcePng() {
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 ultimail-original.png or utlimail-original.png in public/brand/")
}
/** Après trim : bbox du picto → carré transparent, picto centré + padding symétrique (évite off-center UI). */
async function padToCenteredSquare(trimmedPng) {
const tMeta = await sharp(trimmedPng).metadata()
const tw = tMeta.width ?? 0
const th = tMeta.height ?? 0
if (!tw || !th) throw new Error("trim produced empty image")
const base = Math.max(tw, th)
const padding = Math.max(8, Math.round(base * 0.055))
const sq = base + padding * 2
const left = Math.floor((sq - tw) / 2)
const top = Math.floor((sq - th) / 2)
return sharp({
create: {
width: sq,
height: sq,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: trimmedPng, left, top }])
.png({ compressionLevel: 9, effort: 9 })
.toBuffer()
}
async function main() {
mkdirSync(brandDir, { recursive: true })
mkdirSync(appDir, { recursive: true })
const src = findSourcePng()
const canonical = join(brandDir, "ultimail-original.png")
if (src !== canonical) copyFileSync(src, canonical)
const base = sharp(canonical)
const meta = await base.metadata()
const W = meta.width ?? 0
const H = meta.height ?? 0
// Bande ~62 % : picto un peu plus bas dans le master, besoin marge sous le glyphe.
const splitY = Math.min(H - 32, Math.max(80, Math.round(H * 0.62)))
const topBuf = await base.clone().extract({ left: 0, top: 0, width: W, height: splitY }).png().toBuffer()
const side = Math.min(W, splitY)
const left = Math.max(0, Math.floor((W - side) / 2))
// Toujours haut de bandeau : si W < splitY, un centrage vertical coupait le haut du picto.
const topCrop = 0
const canvas = { r: 250, g: 251, b: 252, alpha: 1 }
const inner = 640
const { data, info } = await sharp(topBuf)
.extract({ left, top: topCrop, width: side, height: side })
.resize(inner, inner, {
fit: "contain",
position: "north",
background: canvas,
})
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
if (info.channels !== 4) throw new Error("expected RGBA from ensureAlpha")
const cleared = knockOutBackground(data, info.width, info.height)
boostPurpleTopRight(cleared, info.width, info.height)
const trimmedPng = await sharp(Buffer.from(cleared), {
raw: { width: info.width, height: info.height, channels: 4 },
})
.trim({ threshold: 8 })
.png({ compressionLevel: 9, effort: 9 })
.toBuffer()
const squared = await padToCenteredSquare(trimmedPng)
const resizeOpts = { fit: "contain", position: "centre", background: { r: 0, g: 0, b: 0, alpha: 0 } }
const headerOut = join(brandDir, "ultimail-header-icon.png")
await sharp(squared).resize(288, 288, resizeOpts).png({ compressionLevel: 9, effort: 9 }).toFile(headerOut)
console.log("Wrote", headerOut)
const fav32 = join(appDir, "icon.png")
await sharp(squared).resize(32, 32, resizeOpts).png({ compressionLevel: 9 }).toFile(fav32)
console.log("Wrote", fav32)
const apple = join(appDir, "apple-icon.png")
await sharp(squared).resize(180, 180, resizeOpts).png({ compressionLevel: 9 }).toFile(apple)
console.log("Wrote", apple)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})