Skip to content
File

Blob: scripts/build-icons.ts

typescript129 lines
1import { writeFileSync, mkdirSync } from "node:fs";
2import { resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4import sharp from "sharp";
5 
6const ROOT = resolve(fileURLToPath(import.meta.url), "../..");
7const OUT = resolve(ROOT, "public/icons");
8 
9const BG = "#171717";
10const FG = "#A78BFA";
11 
12type Variant = {
13 contentScale: number;
14 strokeScale: number;
15 strokeLinecap: "round" | "butt" | "square";
16};
17 
18const FULL: Variant = { contentScale: 0.82, strokeScale: 1, strokeLinecap: "round" };
19const MASKABLE: Variant = { contentScale: 0.6, strokeScale: 1, strokeLinecap: "round" };
20const TINY: Variant = { contentScale: 0.92, strokeScale: 1.45, strokeLinecap: "butt" };
21 
22function 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 
42async 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 
54function buildSvgFromString(svg: string, newSize: number): string {
55 return svg.replace(/width="\d+"\s+height="\d+"/, `width="${newSize}" height="${newSize}"`);
56}
57 
58function 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 
84async 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 
125main().catch((err) => {
126 console.error(err);
127 process.exit(1);
128});