portofel / client / scripts / make-icons.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// Generates PWA icons (a sage wallet with its clasp, on dark) as PNGs with
// zero dependencies: per-pixel SDF rendering + hand-rolled PNG encoding via
// node:zlib.
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "icons");
mkdirSync(outDir, { recursive: true });

const BG = [13, 17, 23];
const SAGE = [163, 190, 140];

const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const smooth = (d) => clamp(0.5 - d, 0, 1); // 1px antialias band around SDF zero

function roundedRect(px, py, cx, cy, hw, hh, r) {
  const qx = Math.abs(px - cx) - hw + r;
  const qy = Math.abs(py - cy) - hh + r;
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
const circle = (px, py, cx, cy, r) => Math.hypot(px - cx, py - cy) - r;

function triangle(px, py, a, b, c) {
  // signed distance to triangle (positive outside)
  const seg = (p, q) => {
    const ex = q[0] - p[0], ey = q[1] - p[1];
    const wx = px - p[0], wy = py - p[1];
    const t = clamp((wx * ex + wy * ey) / (ex * ex + ey * ey), 0, 1);
    return [wx - ex * t, wy - ey * t];
  };
  const ds = [seg(a, b), seg(b, c), seg(c, a)].map(([x, y]) => x * x + y * y);
  const s1 = (b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0]);
  const s2 = (c[0] - b[0]) * (py - b[1]) - (c[1] - b[1]) * (px - b[0]);
  const s3 = (a[0] - c[0]) * (py - c[1]) - (a[1] - c[1]) * (px - c[0]);
  const inside = (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0);
  return (inside ? -1 : 1) * Math.sqrt(Math.min(...ds));
}

function draw(S, { maskable = false, opaque = false } = {}) {
  const img = Buffer.alloc(S * S * 4);
  const pad = maskable ? 0.1 * S : 0; // maskable safe zone
  const bgRadius = maskable || opaque ? 0 : 0.22 * S;
  const u = (S - 2 * pad) / 1; // content scale
  const off = pad;
  for (let y = 0; y < S; y++) {
    for (let x = 0; x < S; x++) {
      const px = x + 0.5, py = y + 0.5;
      let r = 0, g = 0, b = 0, a = 0;
      // background card
      const dBg = roundedRect(px, py, S / 2, S / 2, S / 2, S / 2, bgRadius);
      const cBg = maskable || opaque ? 1 : smooth(dBg);
      if (cBg > 0) { [r, g, b] = BG; a = cBg; }
      // the wallet: body + the clasp tab on the right (sage)
      const nx = off + 0.5 * u, ny = off + 0.52 * u;
      const dBody = roundedRect(px, py, nx, ny, 0.30 * u, 0.21 * u, 0.06 * u);
      const dTab = roundedRect(px, py, off + 0.72 * u, ny, 0.12 * u, 0.085 * u, 0.045 * u);
      const cSage = smooth(Math.min(dBody, dTab));
      if (cSage > 0) {
        r = r + (SAGE[0] - r) * cSage;
        g = g + (SAGE[1] - g) * cSage;
        b = b + (SAGE[2] - b) * cSage;
        a = Math.max(a, cSage);
      }
      // punched in bg color: the fold line along the top, the clasp button
      const dFold = roundedRect(px, py, nx, off + 0.375 * u, 0.30 * u, 0.011 * u, 0.011 * u);
      const dClasp = circle(px, py, off + 0.72 * u, ny, 0.038 * u);
      const cPunch = smooth(Math.min(dFold, dClasp));
      if (cPunch > 0) {
        r = r + (BG[0] - r) * cPunch;
        g = g + (BG[1] - g) * cPunch;
        b = b + (BG[2] - b) * cPunch;
      }
      const i = (y * S + x) * 4;
      img[i] = r; img[i + 1] = g; img[i + 2] = b; img[i + 3] = Math.round(a * 255);
    }
  }
  return img;
}

function png(S, rgba) {
  const crcTable = [];
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    crcTable[n] = c >>> 0;
  }
  const crc = (buf) => {
    let c = 0xffffffff;
    for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  };
  const chunk = (type, data) => {
    const len = Buffer.alloc(4);
    len.writeUInt32BE(data.length);
    const td = Buffer.concat([Buffer.from(type), data]);
    const cc = Buffer.alloc(4);
    cc.writeUInt32BE(crc(td));
    return Buffer.concat([len, td, cc]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(S, 0);
  ihdr.writeUInt32BE(S, 4);
  ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
  const raw = Buffer.alloc(S * (S * 4 + 1));
  for (let y = 0; y < S; y++) {
    raw[y * (S * 4 + 1)] = 0; // filter: none
    rgba.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4);
  }
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk("IHDR", ihdr),
    chunk("IDAT", deflateSync(raw, { level: 9 })),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}

const targets = [
  ["icon-192.png", 192, {}],
  ["icon-512.png", 512, {}],
  ["icon-512-maskable.png", 512, { maskable: true }],
  ["apple-touch-icon.png", 180, { opaque: true }],
];
for (const [name, size, opts] of targets) {
  writeFileSync(join(outDir, name), png(size, draw(size, opts)));
  console.log("wrote", name);
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/portofel.git