gen-icon-ico.js 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. "use strict";
  2. /**
  3. * Assemble a Windows multi-resolution .ico from the icon.iconset PNGs.
  4. *
  5. * ICO format (PNG-embedded, supported by Windows Vista+):
  6. * ICONDIR (6 bytes): reserved=0, type=1, count=N
  7. * ICONDIRENTRY (16×N): w,h,colors,reserved,planes,bitcount,size,offset
  8. * <PNG bytes concatenated>
  9. * No native tooling (iconutil/sips) — pure Node, cross-buildable on macOS.
  10. */
  11. const fs = require("node:fs");
  12. const path = require("node:path");
  13. const ICONSET = path.join(__dirname, "..", "assets", "icon.iconset");
  14. const OUT = path.join(__dirname, "..", "assets", "icon.ico");
  15. const SIZES = [16, 32, 48, 64, 128, 256];
  16. const SOURCES = {
  17. 16: "icon_16x16.png",
  18. 32: "icon_32x32.png",
  19. 64: "icon_32x32@2x.png",
  20. 128: "icon_128x128.png",
  21. 256: "icon_256x256.png",
  22. // 48 has no direct source; reuse 64 — Windows scales acceptably.
  23. 48: "icon_32x32@2x.png",
  24. };
  25. function pngDims(buf) {
  26. // PNG IHDR: width (4 bytes BE) at offset 16, height at 20.
  27. return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
  28. }
  29. function build() {
  30. const entries = [];
  31. for (const size of SIZES) {
  32. const file = path.join(ICONSET, SOURCES[size]);
  33. if (!fs.existsSync(file)) { console.warn(`skip ${size}: missing ${SOURCES[size]}`); continue; }
  34. const buf = fs.readFileSync(file);
  35. if (buf.slice(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`${file}: not a PNG`);
  36. const { width, height } = pngDims(buf);
  37. entries.push({ size, width, height, buf });
  38. }
  39. if (entries.length === 0) throw new Error("no PNG sources found");
  40. const count = entries.length;
  41. const headerSize = 6 + 16 * count;
  42. let offset = headerSize;
  43. const dir = Buffer.alloc(headerSize);
  44. dir.writeUInt16LE(0, 0); // reserved
  45. dir.writeUInt16LE(1, 2); // type = icon
  46. dir.writeUInt16LE(count, 4);
  47. entries.forEach((e, i) => {
  48. const base = 6 + 16 * i;
  49. dir.writeUInt8(e.size >= 256 ? 0 : e.size, base); // width (0 = 256)
  50. dir.writeUInt8(e.size >= 256 ? 0 : e.size, base + 1); // height
  51. dir.writeUInt8(0, base + 2); // colorCount (0 = ≥8bit)
  52. dir.writeUInt8(0, base + 3); // reserved
  53. dir.writeUInt16LE(1, base + 4); // planes
  54. dir.writeUInt16LE(32, base + 6); // bitCount
  55. dir.writeUInt32LE(e.buf.length, base + 8); // bytesInRes
  56. dir.writeUInt32LE(offset, base + 12); // imageOffset
  57. offset += e.buf.length;
  58. });
  59. const out = Buffer.concat([dir, ...entries.map((e) => e.buf)]);
  60. fs.writeFileSync(OUT, out);
  61. console.log(`built ${OUT}: ${count} sizes [${entries.map((e) => e.size).join(",")}] (${out.length} bytes)`);
  62. }
  63. build();