gen-tabbar-icons.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. /**
  2. * tabBar 图标生成脚本(零依赖:内置 zlib + 手写最小 PNG 编码器)
  3. *
  4. * 生成 81x81 RGBA 透明底线性图标到 src/static/tabbar/:
  5. * - workbench(四宫格)/ bills(单据)/ profile(人像)
  6. * - 普通态 #909399,选中态 #2979FF(与 pages.json tabBar 配色一致)
  7. *
  8. * 用法:npm run icons:gen
  9. */
  10. import { deflateSync } from 'node:zlib'
  11. import { mkdirSync, writeFileSync } from 'node:fs'
  12. import { dirname, resolve } from 'node:path'
  13. import { fileURLToPath } from 'node:url'
  14. const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
  15. const OUT_DIR = resolve(ROOT, 'src/static/tabbar')
  16. const SIZE = 81 // 微信推荐 tabBar 图标尺寸
  17. const SAMPLES = 4 // 每像素 4x4 超采样抗锯齿
  18. const COLOR_NORMAL = [0x90, 0x93, 0x99]
  19. const COLOR_ACTIVE = [0x29, 0x79, 0xff]
  20. // ---------- 最小 PNG 编码器 ----------
  21. let CRC_TABLE = null
  22. function crc32(buf) {
  23. if (!CRC_TABLE) {
  24. CRC_TABLE = new Uint32Array(256)
  25. for (let n = 0; n < 256; n++) {
  26. let c = n
  27. for (let k = 0; k < 8; k++) {
  28. c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
  29. }
  30. CRC_TABLE[n] = c >>> 0
  31. }
  32. }
  33. let c = 0xffffffff
  34. for (let i = 0; i < buf.length; i++) {
  35. c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
  36. }
  37. return (c ^ 0xffffffff) >>> 0
  38. }
  39. function chunk(type, data) {
  40. const len = Buffer.alloc(4)
  41. len.writeUInt32BE(data.length)
  42. const typeBuf = Buffer.from(type, 'ascii')
  43. const crc = Buffer.alloc(4)
  44. crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])))
  45. return Buffer.concat([len, typeBuf, data, crc])
  46. }
  47. function encodePng(width, height, rgba) {
  48. const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  49. const ihdr = Buffer.alloc(13)
  50. ihdr.writeUInt32BE(width, 0)
  51. ihdr.writeUInt32BE(height, 4)
  52. ihdr[8] = 8 // 位深
  53. ihdr[9] = 6 // RGBA
  54. const stride = width * 4
  55. const raw = Buffer.alloc(height * (stride + 1))
  56. for (let y = 0; y < height; y++) {
  57. raw[y * (stride + 1)] = 0 // 每行 filter 0
  58. rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
  59. }
  60. const idat = deflateSync(raw, { level: 9 })
  61. return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))])
  62. }
  63. // ---------- 形状 SDF(返回像素点是否落在描边内) ----------
  64. /** 圆角矩形描边 |sdf| <= w/2 */
  65. function roundRect(px, py, cx, cy, hw, hh, r, w) {
  66. const qx = Math.abs(px - cx) - (hw - r)
  67. const qy = Math.abs(py - cy) - (hh - r)
  68. const d =
  69. Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r
  70. return Math.abs(d) <= w / 2
  71. }
  72. /** 圆形描边 */
  73. function circle(px, py, cx, cy, r, w) {
  74. return Math.abs(Math.hypot(px - cx, py - cy) - r) <= w / 2
  75. }
  76. /** 水平胶囊线段(横线) */
  77. function hCapsule(px, py, x1, x2, y, r) {
  78. const cx = Math.min(Math.max(px, x1), x2)
  79. return Math.hypot(px - cx, py - y) <= r
  80. }
  81. // ---------- 三个图标(inside(x, y) → boolean) ----------
  82. /** 工作台:四宫格(4 个圆角方框) */
  83. function iconWorkbench(x, y) {
  84. const cells = [
  85. [24, 24],
  86. [57, 24],
  87. [24, 57],
  88. [57, 57]
  89. ]
  90. return cells.some(([cx, cy]) => roundRect(x, y, cx, cy, 14, 14, 5, 5))
  91. }
  92. /** 单据:圆角矩形外框 + 三条横线 */
  93. function iconBills(x, y) {
  94. if (roundRect(x, y, 40.5, 40.5, 22, 30, 7, 5)) return true
  95. return [29, 41, 53].some((ly) => hCapsule(x, y, 30, 51, ly, 2.5))
  96. }
  97. /** 我的:圆头 + 肩弧 */
  98. function iconProfile(x, y) {
  99. if (circle(x, y, 40.5, 26, 12, 5)) return true
  100. // 肩弧:大圆描边裁剪出顶部弧段
  101. return y <= 62 && circle(x, y, 40.5, 78, 26, 5)
  102. }
  103. // ---------- 渲染与输出 ----------
  104. function render(inside, [r, g, b]) {
  105. const rgba = Buffer.alloc(SIZE * SIZE * 4)
  106. const step = 1 / SAMPLES
  107. for (let y = 0; y < SIZE; y++) {
  108. for (let x = 0; x < SIZE; x++) {
  109. let hit = 0
  110. for (let sy = 0; sy < SAMPLES; sy++) {
  111. for (let sx = 0; sx < SAMPLES; sx++) {
  112. if (inside(x + (sx + 0.5) * step, y + (sy + 0.5) * step)) hit++
  113. }
  114. }
  115. const alpha = Math.round((hit / (SAMPLES * SAMPLES)) * 255)
  116. const idx = (y * SIZE + x) * 4
  117. rgba[idx] = r
  118. rgba[idx + 1] = g
  119. rgba[idx + 2] = b
  120. rgba[idx + 3] = alpha
  121. }
  122. }
  123. return encodePng(SIZE, SIZE, rgba)
  124. }
  125. const icons = [
  126. { name: 'workbench', draw: iconWorkbench },
  127. { name: 'bills', draw: iconBills },
  128. { name: 'profile', draw: iconProfile }
  129. ]
  130. mkdirSync(OUT_DIR, { recursive: true })
  131. for (const { name, draw } of icons) {
  132. const normal = resolve(OUT_DIR, `${name}.png`)
  133. const active = resolve(OUT_DIR, `${name}-active.png`)
  134. writeFileSync(normal, render(draw, COLOR_NORMAL))
  135. writeFileSync(active, render(draw, COLOR_ACTIVE))
  136. console.log(`[icons] ${name}.png / ${name}-active.png 已生成`)
  137. }
  138. console.log(`[icons] 输出目录: ${OUT_DIR}`)