前言给网站做 favicon常规路子是找在线生成器或者npm install sharp/png-to-ico一把梭。但我的场景比较刁部署在一台零依赖的 Node 服务器上整个项目连package.json都没有为一个 4KB 的图标引入图像库有点杀鸡用牛刀。于是我研究了一下 PNG 和 ICO 的文件格式发现这俩格式简单得出奇——纯 Node 内置模块Buffer zlib就能手写一个 PNG 编码器配上一个用有向距离场SDF画圆角矩形的小渲染器150 行代码搞定。最终效果输入三个参数尺寸、两种渐变色输出一个渐变底 白色圆角工具块图案的.ico浏览器、搜索引擎站点图标Google 搜索结果里的 favicon都能正常识别。我的五个站点用了同一套代码、不同主色生成了一组家族脸图标。先看成品node gen_favicon.js out.ico 55,138,221 91,157,249生成 32×32 的 ICO蓝橙渐变底四个白色圆角小块 2×2 排列。改两个 RGB 参数就能换配色。第一步理解 PNG 文件结构PNG 一个 8 字节签名 若干个块chunk。我们只需要三块块作用IHDR宽、高、位深、颜色类型等头信息13 字节定长IDAT像素数据zlib 压缩后的 RGBAIEND结束标记空块每个 chunk 的结构[4字节长度][4字节类型][数据][4字节CRC32校验]难点只有一个CRC32。好消息是 zlib 内置了constzlibrequire(zlib);constcrc32(buf)zlib.crc32?zlib.crc32(buf):softCrc32(buf);// Node 22 自带 zlib.crc32低版本用查表法兜底第二步手写 PNG 编码器constzlibrequire(zlib);functionchunk(type,data){constlenBuffer.alloc(4);len.writeUInt32BE(data.length);consttypeBufBuffer.from(type,ascii);constcrcBuffer.alloc(4);crc.writeUInt32BE(zlib.crc32(Buffer.concat([typeBuf,data]))0);returnBuffer.concat([len,typeBuf,data,crc]);}functionencodePNG(w,h,rgba){// IHDR: 位深8 颜色类型6 RGBAconstihdrBuffer.alloc(13);ihdr.writeUInt32BE(w,0);ihdr.writeUInt32BE(h,4);ihdr[8]8;// bit depthihdr[9]6;// color type RGBA// IDAT: 每行前面加 1 字节过滤器 0None再整块 zlib 压缩conststridew*4;constrawBuffer.alloc((stride1)*h);for(lety0;yh;y){raw[y*(stride1)]0;// filter: Nonergba.copy(raw,y*(stride1)1,y*stride,(y1)*stride);}constidatzlib.deflateSync(raw,{level:9});returnBuffer.concat([Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]),// 签名chunk(IHDR,ihdr),chunk(IDAT,idat),chunk(IEND,Buffer.alloc(0)),]);}就这么几十行一个合规的 PNG 编码器完成了。踩坑提醒IDAT 的像素数据每行开头必须有一个 filter 字节哪怕你不用任何过滤器也要写 0漏了它 zlib 能压过去、浏览器却解码出一条条斜纹——这是我调了半小时才发现的特色。第三步用 SDF 画圆角矩形位图绘制最朴素的思路是逐像素判断我在不在形状里。圆角矩形用有向距离场来做非常优雅// 点 (px,py) 到圆角矩形中心距离场r 为圆角半径// 返回值 0 表示在矩形内部0 表示在外部数值≈到边缘的距离functionsdfRoundRect(px,py,hw,hh,r){constqxMath.abs(px)-hwr;constqyMath.abs(py)-hhr;constaxMath.max(qx,0),ayMath.max(qy,0);returnMath.hypot(ax,ay)Math.min(Math.max(qx,qy),0)-r;}有了它边缘抗锯齿也顺手解决了像素中心距离在[-1, 1]之间的按距离线性混合透明度边缘就是平滑渐变而不是锯齿楼梯。渲染主体先画一个渐变底对角线线性插值两色再叠四块白色圆角小矩形functionrender(size,c1,c2){constbufBuffer.alloc(size*size*4);constpadsize*0.14;// 外边距constgapsize*0.06;// 块间距constcell(size-pad*2-gap)/2;consthalfcell/2;constrcell*0.28;// 圆角半径constcenters[[-1,-1],[1,-1],[-1,1],[1,1],].map(([sx,sy])[size/2sx*(halfgap/2),size/2sy*(halfgap/2)]);for(lety0;ysize;y){for(letx0;xsize;x){consti(y*sizex)*4;// 渐变底对角线constt(xy)/(2*size-2);letRc1[0](c2[0]-c1[0])*t;letGc1[1](c2[1]-c1[1])*t;letBc1[2](c2[2]-c1[2])*t;letA255;// 叠加白色块任一块命中即白lethit1e9;for(const[cx,cy]ofcenters){hitMath.min(hit,sdfRoundRect(x-cx,y-cy,half,half,r));}if(hit1){constalphaMath.min(1,Math.max(0,(1-hit)/2));// 抗锯齿R255*alphaR*(1-alpha);G255*alphaG*(1-alpha);B255*alphaB*(1-alpha);}buf[i]R|0;buf[i1]G|0;buf[i2]B|0;buf[i3]A;}}returnbuf;}第四步包一层 ICO 壳ICO 格式比想象中还简单一个 6 字节头 一条 16 字节目录项 原始 PNG 数据。现代 ICO 支持内嵌 PNG256×256 内都合法不用再写 BMP 行序转换functionwrapICO(png){constheadBuffer.alloc(6);head.writeUInt16LE(0,0);// 保留位head.writeUInt16LE(1,2);// 类型1 图标head.writeUInt16LE(1,4);// 图像数量constdirBuffer.alloc(16);dir[0]32;dir[1]32;// 宽高0 表示 256dir[2]0;dir[3]0;// 调色板dir.writeUInt16LE(1,4);// 色彩平面dir.writeUInt16LE(32,6);// 位深dir.writeUInt32LE(png.length,8);// 数据大小dir.writeUInt32LE(22,12);// 数据偏移 6 16returnBuffer.concat([head,dir,png]);}组装入口constoutprocess.argv[2]||favicon.ico;constparse(s)s.split(,).map(Number);constc1parse(process.argv[3]||55,138,221);constc2parse(process.argv[4]||91,157,249);require(fs).writeFileSync(out,wrapICO(encodePNG(32,32,render(32,c1,c2))));console.log(OK -,out);实用建议多尺寸怎么办严格的 favicon 规范建议提供 16/32/48 三档。做法是把三个尺寸各自encodePNG后在 ICO 里放三条目录项把wrapICO里的数量改成 3、循环写目录项即可代码模式完全一样。我的实践里单 32×32 在 Chrome/Edge/Google 搜索结果里显示都正常48 以上的场景PWA、桌面快捷方式才需要补。总结PNG 编码没有想象中神秘签名 IHDR/IDAT/IEND 三个 chunk CRC32zlib 自带IDAT 每行的 filter 字节是最隐蔽的坑SDF 是位图绘制圆角形状 抗锯齿的优雅解法ICO 现代格式直接内嵌 PNG外壳只有 22 字节完整代码约 130 行零依赖Node 18 直接跑。适合的场景CI/CD 里动态生成图标、给批量站点生成同款不同色的图标家族、或者单纯想搞懂图片文件格式。完整可运行源码我整理在文末附件改配色只需要动两个 RGB 参数。有问题欢迎评论区交流。