/** * 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) })