gen-icons.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. "use strict";
  2. /**
  3. * Render DSH Desktop icons from the official DeepSeek whale path
  4. * (assets-src/whale-path.txt, extracted from dsh web's /favicon.svg).
  5. *
  6. * Pure-Node rasterizer — cubic-bezier flattening + nonzero-winding scanline
  7. * fill + 4× supersampling AA. (qlmanage is NOT usable: it composites SVG
  8. * thumbnails onto an opaque white background, destroying transparency.)
  9. *
  10. * Outputs:
  11. * assets/trayTemplate.png (20×16) + trayTemplate@2x.png (40×32) — black
  12. * whale, alpha-only → macOS menu-bar template images
  13. * assets/icon.iconset/icon_*.png — DeepSeek-blue rounded square with a
  14. * white whale; `iconutil` then builds assets/icon.icns
  15. */
  16. const fs = require("node:fs");
  17. const path = require("node:path");
  18. const zlib = require("node:zlib");
  19. const clamp01 = (v) => Math.max(0, Math.min(1, v));
  20. const lerp = (a, b, t) => a + (b - a) * t;
  21. // --- minimal PNG writer (8-bit RGBA) ----------------------------------------
  22. const CRC_TABLE = (() => {
  23. const table = new Int32Array(256);
  24. for (let n = 0; n < 256; n++) {
  25. let c = n;
  26. for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
  27. table[n] = c;
  28. }
  29. return table;
  30. })();
  31. function crc32(buf) {
  32. let c = -1;
  33. for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 255] ^ (c >>> 8);
  34. return (c ^ -1) >>> 0;
  35. }
  36. function chunk(type, data) {
  37. const len = Buffer.alloc(4);
  38. len.writeUInt32BE(data.length);
  39. const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
  40. const crc = Buffer.alloc(4);
  41. crc.writeUInt32BE(crc32(body));
  42. return Buffer.concat([len, body, crc]);
  43. }
  44. function encodePng(w, h, rgba) {
  45. const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
  46. const ihdr = Buffer.alloc(13);
  47. ihdr.writeUInt32BE(w, 0);
  48. ihdr.writeUInt32BE(h, 4);
  49. ihdr[8] = 8; // bit depth
  50. ihdr[9] = 6; // RGBA
  51. const stride = w * 4;
  52. const raw = Buffer.alloc((stride + 1) * h);
  53. for (let y = 0; y < h; y++) {
  54. raw[y * (stride + 1)] = 0; // filter: none
  55. rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
  56. }
  57. return Buffer.concat([
  58. sig,
  59. chunk("IHDR", ihdr),
  60. chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
  61. chunk("IEND", Buffer.alloc(0)),
  62. ]);
  63. }
  64. // --- whale path → flattened polygons -----------------------------------------
  65. /** The path uses only absolute M/C/Z (verified: 4 M, 71 C, 4 Z). */
  66. function parsePath(d) {
  67. const tokens = d.match(/[MCZ]|-?\d+(?:\.\d+)?/g);
  68. const subpaths = [];
  69. let current = null;
  70. let i = 0;
  71. while (i < tokens.length) {
  72. const t = tokens[i];
  73. if (t === "M") {
  74. current = [];
  75. subpaths.push(current);
  76. current.push({ x: Number(tokens[i + 1]), y: Number(tokens[i + 2]) });
  77. i += 3;
  78. } else if (t === "C") {
  79. const p0 = current[current.length - 1];
  80. const p1 = { x: Number(tokens[i + 1]), y: Number(tokens[i + 2]) };
  81. const p2 = { x: Number(tokens[i + 3]), y: Number(tokens[i + 4]) };
  82. const p3 = { x: Number(tokens[i + 5]), y: Number(tokens[i + 6]) };
  83. for (let s = 1; s <= 24; s++) {
  84. const u = s / 24;
  85. const v = 1 - u;
  86. current.push({
  87. x: v * v * v * p0.x + 3 * v * v * u * p1.x + 3 * v * u * u * p2.x + u * u * u * p3.x,
  88. y: v * v * v * p0.y + 3 * v * v * u * p1.y + 3 * v * u * u * p2.y + u * u * u * p3.y,
  89. });
  90. }
  91. i += 7;
  92. } else if (t === "Z") {
  93. i += 1;
  94. } else {
  95. throw new Error(`unsupported path command: ${t}`);
  96. }
  97. }
  98. return subpaths;
  99. }
  100. function bboxOf(subpaths) {
  101. let minX = Infinity;
  102. let minY = Infinity;
  103. let maxX = -Infinity;
  104. let maxY = -Infinity;
  105. for (const poly of subpaths) {
  106. for (const p of poly) {
  107. if (p.x < minX) minX = p.x;
  108. if (p.x > maxX) maxX = p.x;
  109. if (p.y < minY) minY = p.y;
  110. if (p.y > maxY) maxY = p.y;
  111. }
  112. }
  113. return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
  114. }
  115. /** Scale the whale so its height is targetH, centered on the canvas. */
  116. function place(subpaths, canvasW, canvasH, targetH) {
  117. const b = bboxOf(subpaths);
  118. const s = targetH / b.h;
  119. const tx = (canvasW - b.w * s) / 2 - b.minX * s;
  120. const ty = (canvasH - targetH) / 2 - b.minY * s;
  121. return subpaths.map((poly) => poly.map((p) => ({ x: p.x * s + tx, y: p.y * s + ty })));
  122. }
  123. const SS = 4; // supersampling factor
  124. /** Nonzero-winding scanline fill at SS×, box-downsampled to an 8-bit mask.
  125. * Input polygons are in final pixel coordinates; they are scaled by SS so the
  126. * scanline pass samples at SS× resolution. */
  127. function rasterizeMask(subpaths, w, h) {
  128. const SW = w * SS;
  129. const SH = h * SS;
  130. const scaled = subpaths.map((poly) => poly.map((p) => ({ x: p.x * SS, y: p.y * SS })));
  131. const sample = new Uint8Array(SW * SH);
  132. for (let y = 0; y < SH; y++) {
  133. const yc = y + 0.5;
  134. const xs = [];
  135. for (const poly of scaled) {
  136. for (let i = 0; i < poly.length; i++) {
  137. const a = poly[i];
  138. const b = poly[(i + 1) % poly.length];
  139. if ((a.y <= yc && b.y > yc) || (b.y <= yc && a.y > yc)) {
  140. const t = (yc - a.y) / (b.y - a.y);
  141. xs.push({ x: a.x + t * (b.x - a.x), dir: b.y > a.y ? 1 : -1 });
  142. }
  143. }
  144. }
  145. xs.sort((p, q) => p.x - q.x);
  146. let winding = 0;
  147. let spanStart = 0;
  148. for (const e of xs) {
  149. if (winding === 0) spanStart = e.x;
  150. winding += e.dir;
  151. if (winding === 0) {
  152. const x0 = Math.max(0, Math.ceil(spanStart - 0.5));
  153. const x1 = Math.min(SW - 1, Math.ceil(e.x - 0.5) - 1);
  154. const row = y * SW;
  155. for (let x = x0; x <= x1; x++) sample[row + x] = 1;
  156. }
  157. }
  158. }
  159. const mask = new Uint8Array(w * h);
  160. for (let y = 0; y < h; y++) {
  161. for (let x = 0; x < w; x++) {
  162. let sum = 0;
  163. for (let sy = 0; sy < SS; sy++) {
  164. const row = (y * SS + sy) * SW + x * SS;
  165. for (let sx = 0; sx < SS; sx++) sum += sample[row + sx];
  166. }
  167. mask[y * w + x] = Math.round((sum / (SS * SS)) * 255);
  168. }
  169. }
  170. return mask;
  171. }
  172. /** Analytic rounded-rect coverage with ~1px edge AA, rect = full canvas. */
  173. function roundRectCov(x, y, size, r) {
  174. const px = x + 0.5;
  175. const py = y + 0.5;
  176. const cx = Math.min(Math.max(px, r), size - r);
  177. const cy = Math.min(Math.max(py, r), size - r);
  178. return clamp01(0.5 - (Math.hypot(px - cx, py - cy) - r));
  179. }
  180. const WHALE = parsePath(fs.readFileSync(path.join(__dirname, "..", "assets-src", "whale-path.txt"), "utf8").trim());
  181. /** App icon: vertical-gradient DeepSeek-blue rounded square + white whale. */
  182. function drawIcon(size) {
  183. const whale = rasterizeMask(place(WHALE, size, size, size * 0.62), size, size);
  184. const r = size * 0.2246;
  185. const buf = Buffer.alloc(size * size * 4);
  186. for (let y = 0; y < size; y++) {
  187. const t = y / (size - 1);
  188. const bgR = lerp(0x5e, 0x3d, t);
  189. const bgG = lerp(0x7b, 0x5b, t);
  190. const bgB = lerp(0xff, 0xf5, t);
  191. for (let x = 0; x < size; x++) {
  192. const bgA = roundRectCov(x, y, size, r);
  193. const wA = whale[y * size + x] / 255; // whale OVER background
  194. const outA = wA + bgA * (1 - wA);
  195. if (outA <= 0) continue;
  196. const i = (y * size + x) * 4;
  197. buf[i] = Math.round((255 * wA + bgR * bgA * (1 - wA)) / outA);
  198. buf[i + 1] = Math.round((255 * wA + bgG * bgA * (1 - wA)) / outA);
  199. buf[i + 2] = Math.round((255 * wA + bgB * bgA * (1 - wA)) / outA);
  200. buf[i + 3] = Math.round(outA * 255);
  201. }
  202. }
  203. return encodePng(size, size, buf);
  204. }
  205. /** Tray template: black whale, alpha-only (macOS renders template by alpha). */
  206. function drawTray(w, h, whaleH) {
  207. const whale = rasterizeMask(place(WHALE, w, h, whaleH), w, h);
  208. const buf = Buffer.alloc(w * h * 4);
  209. for (let p = 0; p < w * h; p++) {
  210. buf[p * 4] = 0;
  211. buf[p * 4 + 1] = 0;
  212. buf[p * 4 + 2] = 0;
  213. buf[p * 4 + 3] = whale[p];
  214. }
  215. return encodePng(w, h, buf);
  216. }
  217. // --- outputs -----------------------------------------------------------------
  218. const assets = path.join(__dirname, "..", "assets");
  219. fs.mkdirSync(assets, { recursive: true });
  220. fs.writeFileSync(path.join(assets, "trayTemplate.png"), drawTray(20, 16, 14));
  221. fs.writeFileSync(path.join(assets, "trayTemplate@2x.png"), drawTray(40, 32, 28));
  222. const iconset = path.join(assets, "icon.iconset");
  223. fs.mkdirSync(iconset, { recursive: true });
  224. const RENDERED = {};
  225. const render = (size) => (RENDERED[size] ??= drawIcon(size));
  226. const SIZES = {
  227. "icon_16x16.png": 16,
  228. "icon_16x16@2x.png": 32,
  229. "icon_32x32.png": 32,
  230. "icon_32x32@2x.png": 64,
  231. "icon_128x128.png": 128,
  232. "icon_128x128@2x.png": 256,
  233. "icon_256x256.png": 256,
  234. "icon_256x256@2x.png": 512,
  235. "icon_512x512.png": 512,
  236. "icon_512x512@2x.png": 1024,
  237. };
  238. for (const [name, size] of Object.entries(SIZES)) {
  239. fs.writeFileSync(path.join(iconset, name), render(size));
  240. }
  241. console.log(`whale bbox: ${JSON.stringify(bboxOf(WHALE))}`);
  242. console.log(`icons rendered into ${assets} (+ icon.iconset; run iconutil for .icns)`);