/** * Exporte les déclinaisons brand (PNG + JPG) depuis les masters raster : * - public/brand/ultimail-original.png (stacked + bandeau texte) * - public/brand/ultimail-header-icon.png (mark / horizontal) * * Ne vectorise pas : les SVG VTracer dégradent couleurs et cadrage. * * pnpm run brand:raster */ import { existsSync } 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") 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 (or legacy utlimail-original.png).") } function headerIconPath() { const p = join(brandDir, "ultimail-header-icon.png") if (!existsSync(p)) { throw new Error("Missing public/brand/ultimail-header-icon.png — run: pnpm run brand:header-icon") } return p } /** Wordmark empilé fond transparent : picto couleurs d'origine, texte éclairci (sans invert/hue). */ async function buildStackedDark(originalPath) { const { data, info } = await sharp(originalPath) .resize({ width: 800, height: 800, fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 1 }, }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }) 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 } const max = Math.max(r, g, b) const min = Math.min(r, g, b) const chroma = max - min // Texte / traits neutres sombres → gris clair lisible sur fond dark. if (chroma < 40 && max < 200) { data[i] = 245 data[i + 1] = 245 data[i + 2] = 247 } data[i + 3] = 255 } const out = join(brandDir, "ultimail-wordmark-stacked-dark.png") await sharp(Buffer.from(data), { raw: { width: info.width, height: info.height, channels: 4 }, }) .png({ compressionLevel: 9 }) .toFile(out) console.log("Wrote ultimail-wordmark-stacked-dark.png") } async function writePngJpg(input, outBase, w, h, bg = "#ffffff") { const resize = { width: w, height: h, fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 1 }, } const pngOut = join(brandDir, `${outBase}.png`) const jpgOut = join(brandDir, `${outBase}.jpg`) await sharp(input).resize(resize).png({ compressionLevel: 9 }).toFile(pngOut) await sharp(input).resize(resize).flatten({ background: bg }).jpeg({ quality: 92, mozjpeg: true }).toFile(jpgOut) console.log(`Wrote ${outBase}.png / .jpg`) } /** Bande basse du master ≈ wordmark « Ultimail ». */ 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() // Deux appels sharp séparés : évite un bug sharp (trim en chaîne après autres exports). return sharp(strip).trim({ threshold: 12 }).png().toBuffer() } async function buildHorizontalWordmark(originalPath, headerIcon) { const iconH = 240 const iconBuf = await sharp(headerIcon) .resize({ height: iconH, fit: "inside" }) .png() .toBuffer() const textBuf = await extractWordmarkTextStrip(originalPath) 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 = 48 const gap = 56 const canvasW = iw + tw + padX * 2 + gap const canvasH = Math.max(ih, th) + 32 return sharp({ create: { width: canvasW, height: canvasH, channels: 3, background: { r: 255, g: 255, b: 255 }, }, }) .composite([ { input: iconBuf, left: padX, top: Math.round((canvasH - ih) / 2) }, { input: textResized, left: padX + iw + gap, top: Math.round((canvasH - th) / 2) }, ]) .png() .toBuffer() } async function main() { const original = findOriginalPng() const headerIcon = headerIconPath() const horizontalBuf = await buildHorizontalWordmark(original, headerIcon) await writePngJpg(headerIcon, "ultimail-mark", 256, 256) await writePngJpg(original, "ultimail-wordmark-stacked", 800, 800) await buildStackedDark(original) await writePngJpg(horizontalBuf, "ultimail-wordmark-horizontal", 1600, 460) } main().catch((e) => { console.error(e) process.exit(1) })