File
Blob: scripts/build-icons.ts
| 1 | import { writeFileSync, mkdirSync } from "node:fs"; |
| 2 | import { resolve } from "node:path"; |
| 3 | import { fileURLToPath } from "node:url"; |
| 4 | import sharp from "sharp"; |
| 5 | |
| 6 | const ROOT = resolve(fileURLToPath(import.meta.url), "../.."); |
| 7 | const OUT = resolve(ROOT, "public/icons"); |
| 8 | |
| 9 | const BG = "#171717"; |
| 10 | const FG = "#A78BFA"; |
| 11 | |
| 12 | type Variant = { |
| 13 | contentScale: number; |
| 14 | strokeScale: number; |
| 15 | strokeLinecap: "round" | "butt" | "square"; |
| 16 | }; |
| 17 | |
| 18 | const FULL: Variant = { contentScale: 0.82, strokeScale: 1, strokeLinecap: "round" }; |
| 19 | const MASKABLE: Variant = { contentScale: 0.6, strokeScale: 1, strokeLinecap: "round" }; |
| 20 | const TINY: Variant = { contentScale: 0.92, strokeScale: 1.45, strokeLinecap: "butt" }; |
| 21 | |
| 22 | function buildSvg(size: number, v: Variant, transparent = false): string { |
| 23 | const glyphW = 16; |
| 24 | const glyphH = 18; |
| 25 | const scale = (size * v.contentScale) / Math.max(glyphW, glyphH); |
| 26 | const tx = size / 2 - 12 * scale; |
| 27 | const ty = size / 2 - 12 * scale; |
| 28 | const stroke = 2 * v.strokeScale; |
| 29 | const bgRect = transparent ? "" : ` <rect width="${size}" height="${size}" fill="${BG}"/>`; |
| 30 | return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> |
| 31 | ${bgRect} |
| 32 | <g transform="translate(${tx.toFixed(3)},${ty.toFixed(3)}) scale(${scale.toFixed(4)})" |
| 33 | fill="none" stroke="${FG}" stroke-width="${stroke}" |
| 34 | stroke-linecap="${v.strokeLinecap}" stroke-linejoin="round"> |
| 35 | <path d="M13 4v16"/> |
| 36 | <path d="M17 4v16"/> |
| 37 | <path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13"/> |
| 38 | </g> |
| 39 | </svg>`; |
| 40 | } |
| 41 | |
| 42 | async function renderPng(svg: string, size: number, opts: { alpha?: boolean } = {}): Promise<Buffer> { |
| 43 | const supersample = size < 64 ? 4 : 2; |
| 44 | const source = buildSvgFromString(svg, size * supersample); |
| 45 | let pipeline = sharp(Buffer.from(source)).resize(size, size, { |
| 46 | kernel: sharp.kernel.lanczos3, |
| 47 | }); |
| 48 | if (opts.alpha === false) { |
| 49 | pipeline = pipeline.flatten({ background: BG }); |
| 50 | } |
| 51 | return pipeline.png({ compressionLevel: 9 }).toBuffer(); |
| 52 | } |
| 53 | |
| 54 | function buildSvgFromString(svg: string, newSize: number): string { |
| 55 | return svg.replace(/width="\d+"\s+height="\d+"/, `width="${newSize}" height="${newSize}"`); |
| 56 | } |
| 57 | |
| 58 | function buildIco(pngs: Buffer[]): Buffer { |
| 59 | const header = Buffer.alloc(6); |
| 60 | header.writeUInt16LE(0, 0); |
| 61 | header.writeUInt16LE(1, 2); |
| 62 | header.writeUInt16LE(pngs.length, 4); |
| 63 | |
| 64 | const entries = Buffer.alloc(16 * pngs.length); |
| 65 | const sizes = [16, 32, 48]; |
| 66 | let offset = header.length + entries.length; |
| 67 | pngs.forEach((png, i) => { |
| 68 | const size = sizes[i]; |
| 69 | const base = i * 16; |
| 70 | entries.writeUInt8(size === 256 ? 0 : size, base + 0); |
| 71 | entries.writeUInt8(size === 256 ? 0 : size, base + 1); |
| 72 | entries.writeUInt8(0, base + 2); |
| 73 | entries.writeUInt8(0, base + 3); |
| 74 | entries.writeUInt16LE(1, base + 4); |
| 75 | entries.writeUInt16LE(32, base + 6); |
| 76 | entries.writeUInt32LE(png.length, base + 8); |
| 77 | entries.writeUInt32LE(offset, base + 12); |
| 78 | offset += png.length; |
| 79 | }); |
| 80 | |
| 81 | return Buffer.concat([header, entries, ...pngs]); |
| 82 | } |
| 83 | |
| 84 | async function main() { |
| 85 | mkdirSync(OUT, { recursive: true }); |
| 86 | |
| 87 | const canonicalSvg = buildSvg(512, FULL, true); |
| 88 | writeFileSync(resolve(OUT, "icon.svg"), canonicalSvg); |
| 89 | |
| 90 | const targets: Array<{ |
| 91 | name: string; |
| 92 | size: number; |
| 93 | variant: Variant; |
| 94 | alpha: boolean; |
| 95 | }> = [ |
| 96 | { name: "favicon-16x16.png", size: 16, variant: TINY, alpha: true }, |
| 97 | { name: "favicon-32x32.png", size: 32, variant: FULL, alpha: true }, |
| 98 | { name: "apple-touch-icon.png", size: 180, variant: FULL, alpha: false }, |
| 99 | { name: "icon-192.png", size: 192, variant: FULL, alpha: false }, |
| 100 | { name: "icon-512.png", size: 512, variant: FULL, alpha: false }, |
| 101 | { name: "icon-192-maskable.png", size: 192, variant: MASKABLE, alpha: false }, |
| 102 | { name: "icon-512-maskable.png", size: 512, variant: MASKABLE, alpha: false }, |
| 103 | ]; |
| 104 | |
| 105 | for (const t of targets) { |
| 106 | const svg = buildSvg(t.size, t.variant, t.alpha); |
| 107 | const png = await renderPng(svg, t.size, { alpha: t.alpha }); |
| 108 | writeFileSync(resolve(OUT, t.name), png); |
| 109 | console.log(`wrote ${t.name} (${png.length} bytes)`); |
| 110 | } |
| 111 | |
| 112 | const icoSizes: Array<{ size: number; variant: Variant }> = [ |
| 113 | { size: 16, variant: TINY }, |
| 114 | { size: 32, variant: FULL }, |
| 115 | { size: 48, variant: FULL }, |
| 116 | ]; |
| 117 | const icoPngs = await Promise.all( |
| 118 | icoSizes.map(({ size, variant }) => renderPng(buildSvg(size, variant, true), size, { alpha: true })), |
| 119 | ); |
| 120 | const ico = buildIco(icoPngs); |
| 121 | writeFileSync(resolve(OUT, "favicon.ico"), ico); |
| 122 | console.log(`wrote favicon.ico (${ico.length} bytes, ${icoPngs.length} sizes)`); |
| 123 | } |
| 124 | |
| 125 | main().catch((err) => { |
| 126 | console.error(err); |
| 127 | process.exit(1); |
| 128 | }); |