study-lab/scripts/generate-icons.js
scripts/generate-icons.jsRaw
1#!/usr/bin/env node
2
3const fs = require('node:fs');
4const path = require('node:path');
5const zlib = require('node:zlib');
6
7const root = path.resolve(__dirname, '..');
8
9function crc32(buffer) {
10 let crc = 0xffffffff;
11 for (const byte of buffer) {
12 crc ^= byte;
13 for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
14 }
15 return (crc ^ 0xffffffff) >>> 0;
16}
17
18function chunk(type, data) {
19 const name = Buffer.from(type);
20 const length = Buffer.alloc(4);
21 const checksum = Buffer.alloc(4);
22 length.writeUInt32BE(data.length);
23 checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
24 return Buffer.concat([length, name, data, checksum]);
25}
26
27function distanceToSegment(px, py, ax, ay, bx, by) {
28 const dx = bx - ax;
29 const dy = by - ay;
30 const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
31 return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
32}
33
34function onStroke(x, y, points, radius) {
35 for (let index = 1; index < points.length; index += 1) {
36 if (distanceToSegment(x, y, ...points[index - 1], ...points[index]) <= radius) return true;
37 }
38 return false;
39}
40
41function sample(x, y, maskable) {
42 const paper = [255, 255, 255];
43 const accent = [63, 63, 63];
44 const ink = [32, 32, 32];
45 const highlight = [128, 128, 128];
46 let color = maskable ? accent : paper;
47 const blob = ((x - 256) / 162) ** 2 + ((y - 258) / 181) ** 2 <= 1;
48 if (blob) color = ink;
49
50 const sPath = [
51 [330, 171], [309, 150], [270, 143], [228, 151], [201, 175], [195, 204],
52 [205, 229], [235, 246], [278, 260], [310, 277], [320, 304], [314, 335],
53 [287, 361], [245, 369], [207, 358], [184, 335]
54 ];
55 if (blob && onStroke(x, y, sPath, 24)) color = paper;
56 if ((x - 379) ** 2 + (y - 137) ** 2 <= 30 ** 2) color = highlight;
57 return color;
58}
59
60function render(size, maskable = false) {
61 const rows = [];
62 const samples = 2;
63 for (let py = 0; py < size; py += 1) {
64 const row = Buffer.alloc(1 + size * 4);
65 for (let px = 0; px < size; px += 1) {
66 const total = [0, 0, 0];
67 for (let sy = 0; sy < samples; sy += 1) {
68 for (let sx = 0; sx < samples; sx += 1) {
69 const color = sample((px + (sx + 0.5) / samples) * 512 / size, (py + (sy + 0.5) / samples) * 512 / size, maskable);
70 total[0] += color[0]; total[1] += color[1]; total[2] += color[2];
71 }
72 }
73 const offset = 1 + px * 4;
74 row[offset] = Math.round(total[0] / (samples * samples));
75 row[offset + 1] = Math.round(total[1] / (samples * samples));
76 row[offset + 2] = Math.round(total[2] / (samples * samples));
77 row[offset + 3] = 255;
78 }
79 rows.push(row);
80 }
81
82 const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
83 const header = Buffer.alloc(13);
84 header.writeUInt32BE(size, 0);
85 header.writeUInt32BE(size, 4);
86 header[8] = 8;
87 header[9] = 6;
88 const png = Buffer.concat([
89 signature,
90 chunk('IHDR', header),
91 chunk('IDAT', zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
92 chunk('IEND', Buffer.alloc(0))
93 ]);
94 return png;
95}
96
97const outputs = [
98 ['icon-192.png', 192, false],
99 ['icon-512.png', 512, false],
100 ['icon-maskable-512.png', 512, true],
101 ['apple-touch-icon.png', 180, false]
102];
103
104for (const [filename, size, maskable] of outputs) {
105 fs.writeFileSync(path.join(root, 'icons', filename), render(size, maskable));
106}