233 lines
7.1 KiB
JavaScript
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)
|
|
})
|