tint-panel-icons.mjs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. // 面板小标题图标统一化:
  2. // 1. 从 E:\&项目模型文件等\UI图片\Texture\Icon 取维护者指定的 I/Pipe/Q 系列切图;
  3. // 2. 去色(转灰度,保 alpha);
  4. // 3. 用项目调色盘主蓝 #01abea(--cyan)整体染色——灰度值作为亮度系数,
  5. // 保留原图明暗层次(渐变高光仍在),只替换色相;
  6. // 4. 输出到 public/cockpit/assets/icons/panel/*.png(256×256,1x)。
  7. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  8. import { inflateSync, deflateSync } from 'node:zlib'
  9. const SRC = 'E:/&项目模型文件等/UI图片/Texture/Icon'
  10. const OUT = 'public/cockpit/assets/icons/panel'
  11. mkdirSync(OUT, { recursive: true })
  12. // 面板语义 → 源图(维护者规定:I=次要、Pipe=主要、Q=补充)
  13. const MAP = {
  14. 'icon.panel.situation': { src: 'I图标设备.png', name: 'panel-situation.png', meaning: '态势总览(设备机柜)' },
  15. 'icon.panel.infrastructure': { src: 'I图标设备.png', name: 'panel-infrastructure.png', meaning: '基础设施(设备机柜)' },
  16. 'icon.panel.monitoring': { src: 'I图标在线.png', name: 'panel-monitoring.png', meaning: '运行监测(在线)' },
  17. 'icon.panel.alert': { src: 'I图标报警等级.png', name: 'panel-alert.png', meaning: '预警设置(报警等级)' },
  18. 'icon.panel.archive': { src: 'I图标设备开发.png', name: 'panel-archive.png', meaning: '基础档案(设备开发)' },
  19. 'icon.panel.operation': { src: 'I图标在线.png', name: 'panel-operation.png', meaning: '运行信息(在线)' },
  20. 'icon.panel.management': { src: 'I图标设备开发.png', name: 'panel-management.png', meaning: '管理信息(设备开发)' },
  21. 'icon.panel.emergency': { src: 'I图标报警产生.png', name: 'panel-emergency.png', meaning: '应急保障(报警产生)' },
  22. 'icon.panel.overview': { src: 'I图标在线.png', name: 'panel-overview.png', meaning: '监测概览(在线)' },
  23. 'icon.panel.distribution': { src: 'I图标资源整合.png', name: 'panel-distribution.png', meaning: '监测分布(资源整合)' },
  24. 'icon.panel.ledger': { src: 'I图标设备.png', name: 'panel-ledger.png', meaning: '监测点台账(设备)' },
  25. 'icon.panel.current': { src: 'I图标报警产生.png', name: 'panel-current.png', meaning: '当前预警(报警产生)' },
  26. 'icon.panel.history': { src: 'Q图标时间.png', name: 'panel-history.png', meaning: '历史分析(时间)' },
  27. 'icon.panel.efficiency': { src: 'I图标协议实施.png', name: 'panel-efficiency.png', meaning: '预警效率(协议实施)' },
  28. 'icon.panel.analysis': { src: 'I图标规划设计.png', name: 'panel-analysis.png', meaning: '预警分析(规划设计)' },
  29. }
  30. // 主蓝 #01abea 的 RGB(tokens.css --cyan)
  31. const TINT = [0x01, 0xab, 0xea]
  32. function tintPng(buf) {
  33. // PNG 解析:只处理 8-bit RGBA/RGB(源图均为该格式)
  34. if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('非 PNG')
  35. let pos = 8
  36. const idat = []
  37. let width = 0
  38. let height = 0
  39. let bitDepth = 0
  40. let colorType = 0
  41. let palette = null
  42. let trns = null
  43. while (pos < buf.length) {
  44. const len = buf.readUInt32BE(pos)
  45. const type = buf.toString('ascii', pos + 4, pos + 8)
  46. const data = buf.subarray(pos + 8, pos + 8 + len)
  47. if (type === 'IHDR') {
  48. width = data.readUInt32BE(0)
  49. height = data.readUInt32BE(4)
  50. bitDepth = data[8]
  51. colorType = data[9]
  52. if (bitDepth !== 8 || ![2, 3, 6].includes(colorType)) throw new Error(`不支持的 PNG 格式: bit=${bitDepth} color=${colorType}`)
  53. } else if (type === 'PLTE') {
  54. palette = Buffer.from(data)
  55. } else if (type === 'tRNS') {
  56. trns = Buffer.from(data)
  57. } else if (type === 'IDAT') {
  58. idat.push(data)
  59. } else if (type === 'IEND') {
  60. break
  61. }
  62. pos += 12 + len
  63. }
  64. if (!width) throw new Error('缺 IHDR')
  65. // 解压
  66. const raw = inflateSync(Buffer.concat(idat))
  67. const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
  68. const stride = width * channels
  69. const bpp = channels
  70. // 还原滤波
  71. const img = Buffer.alloc(height * stride)
  72. for (let y = 0; y < height; y++) {
  73. const filter = raw[y * (stride + 1)]
  74. const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
  75. const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
  76. const out = img.subarray(y * stride, (y + 1) * stride)
  77. for (let x = 0; x < stride; x++) {
  78. const a = x >= bpp ? out[x - bpp] : 0
  79. const b = prev ? prev[x] : 0
  80. const c = prev && x >= bpp ? prev[x - bpp] : 0
  81. let v = row[x]
  82. if (filter === 1) v += a
  83. else if (filter === 2) v += b
  84. else if (filter === 3) v += (a + b) >> 1
  85. else if (filter === 4) {
  86. const p = a + b - c
  87. const pa = Math.abs(p - a)
  88. const pb = Math.abs(p - b)
  89. const pc = Math.abs(p - c)
  90. v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
  91. }
  92. out[x] = v & 0xff
  93. }
  94. }
  95. // 去色 + 染色,重建 PNG(无压缩 filter:filter=0 逐行)
  96. const outRows = []
  97. for (let y = 0; y < height; y++) {
  98. const rowOut = Buffer.alloc(1 + stride)
  99. rowOut[0] = 0
  100. for (let x = 0; x < width; x++) {
  101. let r
  102. let g
  103. let b
  104. let alpha = 255
  105. if (colorType === 6) {
  106. const o = x * 4
  107. r = img[y * stride + o]
  108. g = img[y * stride + o + 1]
  109. b = img[y * stride + o + 2]
  110. alpha = img[y * stride + o + 3]
  111. } else if (colorType === 2) {
  112. const o = x * 3
  113. r = img[y * stride + o]
  114. g = img[y * stride + o + 1]
  115. b = img[y * stride + o + 2]
  116. } else {
  117. const idx = img[y * stride + x]
  118. const p = palette ? idx * 3 : 0
  119. r = palette ? palette[p] : 0
  120. g = palette ? palette[p + 1] : 0
  121. b = palette ? palette[p + 2] : 0
  122. if (trns && idx < trns.length) alpha = trns[idx]
  123. }
  124. // 灰度(BT.601)作为亮度系数,乘目标色
  125. const lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255
  126. const o = x * channels
  127. rowOut[1 + o] = Math.round(TINT[0] * lum)
  128. rowOut[1 + o + 1] = Math.round(TINT[1] * lum)
  129. rowOut[1 + o + 2] = Math.round(TINT[2] * lum)
  130. if (channels === 4) rowOut[1 + o + 3] = alpha
  131. }
  132. outRows.push(rowOut)
  133. }
  134. // 编码 PNG(无滤波,zlib 默认压缩)
  135. const crc32Table = (() => {
  136. const t = new Int32Array(256)
  137. for (let n = 0; n < 256; n++) {
  138. let c = n
  139. for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
  140. t[n] = c
  141. }
  142. return t
  143. })()
  144. const crc32 = (b) => {
  145. let c = -1
  146. for (const byte of b) c = crc32Table[(c ^ byte) & 0xff] ^ (c >>> 8)
  147. return (c ^ -1) >>> 0
  148. }
  149. const chunk = (type, data) => {
  150. const head = Buffer.alloc(4)
  151. head.writeUInt32BE(data.length)
  152. const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
  153. const crc = Buffer.alloc(4)
  154. crc.writeUInt32BE(crc32(body))
  155. return Buffer.concat([head, body, crc])
  156. }
  157. const ihdr = Buffer.alloc(13)
  158. ihdr.writeUInt32BE(width, 0)
  159. ihdr.writeUInt32BE(height, 4)
  160. ihdr[8] = 8
  161. ihdr[9] = colorType === 6 ? 6 : 2
  162. const rawOut = Buffer.concat(outRows)
  163. const compressed = deflateSync(rawOut, { level: 9 })
  164. return Buffer.concat([
  165. Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  166. chunk('IHDR', ihdr),
  167. chunk('IDAT', compressed),
  168. chunk('IEND', Buffer.alloc(0)),
  169. ])
  170. }
  171. const top = () => {
  172. for (const [key, cfg] of Object.entries(MAP)) {
  173. const srcPath = `${SRC}/${cfg.src}`
  174. if (!existsSync(srcPath)) {
  175. console.log(` ✗ 缺源图: ${cfg.src}`)
  176. continue
  177. }
  178. const out = tintPng(readFileSync(srcPath))
  179. writeFileSync(`${OUT}/${cfg.name}`, out)
  180. console.log(` ✓ ${key} <- ${cfg.src} -> panel/${cfg.name} (${out.length}B)`)
  181. }
  182. }
  183. top()