Files
2026-10-11 12:56:38 +08:00

323 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// mkico:从一张 PNG 生成 Windows 的多尺寸 .ico。
//
// 为什么要自己写:环境里没有 ImageMagick / ffmpeg / Python。
// 标准库的 image/png 能解码能编码,但没有 ICO 编码器,所以 ICO 容器手写。
//
// 缩放用的是 **area average(box filter)**,不是最近邻:
//
// 200x200 → 16x16 是 12.5 倍降采样。最近邻会丢掉 98% 的像素,
// 小尺寸图标会糊成一团、甚至出现孤立亮点。box filter 对每个目标像素
// 求源区域的平均,降采样质量好得多。
//
// 还必须做 **premultiplied alpha** 运算:直接对 RGBA 四个通道求平均,
// 透明区域(RGB=0 但 A=0)会把边缘染黑。正确做法是先乘 alpha 求平均,
// 再反乘回来,否则圆角四周会出现难看的黑边。
//
// 用法:
//
// go run ./tools/mkico -in <源.png> -out build/windows/icon.ico
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"os"
"path/filepath"
)
// sizes 是要放进 ICO 的全部尺寸。
//
// 小尺寸用 BMP DIB 编码、大尺寸(>=96)用 PNG-in-ICO —— 这是 Visual Studio
// 自己的做法:PNG-in-ICO 从 Vista 起才被支持,小尺寸用 BMP 兼容性更好,
// 大尺寸用 PNG 能把体积压下来(256x256 的 BMP 光像素就 256KB)。
var sizes = []int{16, 20, 24, 32, 40, 48, 64, 96, 128, 256}
// pngMinSize 是从多大开始改用 PNG-in-ICO
const pngMinSize = 96
func main() {
in := flag.String("in", "", "源 PNG")
out := flag.String("out", "build/windows/icon.ico", "输出 ICO")
dump := flag.Int("dump", 0, "只导出这个尺寸的缩放结果为 PNG(调试用),不写 ICO")
flag.Parse()
if *in == "" {
log.Fatal("需要 -in <源.png>")
}
f, err := os.Open(*in)
if err != nil {
log.Fatal(err)
}
src, err := png.Decode(f)
f.Close()
if err != nil {
log.Fatal(err)
}
base := image.NewNRGBA(src.Bounds())
draw.Draw(base, base.Bounds(), src, src.Bounds().Min, draw.Src)
// 调试模式:只输出单个尺寸的缩放结果,绕开 ICO 编码,
// 用来判断「颜色不对」到底出在 resize 还是编码。
if *dump > 0 {
img := base
if *dump != base.Rect.Dx() {
img = resize(base, *dump, *dump)
}
name := fmt.Sprintf("dump-%d.png", *dump)
fo, err := os.Create(name)
if err != nil {
log.Fatal(err)
}
defer fo.Close()
if err := png.Encode(fo, img); err != nil {
log.Fatal(err)
}
fmt.Printf("已导出 %s (取样: 中心=%v 左上=%v)\n", name,
img.At(*dump/2, *dump/2), img.At(0, 0))
return
}
b := src.Bounds()
fmt.Printf("源图 %dx%d %v → 生成 %v 个尺寸\n", b.Dx(), b.Dy(), src.ColorModel(), sizes)
ico, err := buildICO(src)
if err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
log.Fatal(err)
}
if err := os.WriteFile(*out, ico, 0o644); err != nil {
log.Fatal(err)
}
fmt.Printf("已写入 %s (%d 字节)\n", *out, len(ico))
}
// ---------------------------------------------------------------- 缩放
// resize 对图像做 box filter 降采样。
//
// 逐目标像素映射到源坐标,按面积加权平均,并用 premultiplied alpha
// 避免透明区把边缘染黑。
func resize(src *image.NRGBA, w, h int) *image.NRGBA {
sw, sh := src.Rect.Dx(), src.Rect.Dy()
dst := image.NewNRGBA(image.Rect(0, 0, w, h))
// 每列/行在源图上的区间(用浮点,最后按像素中心取样)
for dy := 0; dy < h; dy++ {
y0 := float64(dy) * float64(sh) / float64(h)
y1 := float64(dy+1) * float64(sh) / float64(h)
for dx := 0; dx < w; dx++ {
x0 := float64(dx) * float64(sw) / float64(w)
x1 := float64(dx+1) * float64(sw) / float64(w)
var sumR, sumG, sumB, sumA, sumW float64
for sy := int(y0); sy < int(math_Ceil(y1)); sy++ {
if sy < 0 || sy >= sh {
continue
}
// 该源行与目标行区间的重叠长度
wy := overlap(y0, y1, float64(sy), float64(sy+1))
if wy <= 0 {
continue
}
row := src.Pix[sy*src.Stride:]
for sx := int(x0); sx < int(math_Ceil(x1)); sx++ {
if sx < 0 || sx >= sw {
continue
}
wx := overlap(x0, x1, float64(sx), float64(sx+1))
if wx <= 0 {
continue
}
wt := wx * wy
i := sx * 4
// 归一化到 0~1 的 alpha 再预乘。
// ⚠️ 这里必须除 255 —— 直接拿 0~255 的 alpha 去乘,
// sumC 会变成 255*255=65025,后面 clamp8 全 clamp 成 255,
// 橙色 (255,128,3) 会被算成纯白。
af := float64(row[i+3]) / 255
// 预乘 alpha:透明像素的 RGB 不参与颜色计算
sumR += float64(row[i+0]) * af * wt
sumG += float64(row[i+1]) * af * wt
sumB += float64(row[i+2]) * af * wt
sumA += af * wt
sumW += wt
}
}
o := (dy*w + dx) * 4
if sumW == 0 {
continue
}
avgA := sumA / sumW // 0~1
if avgA > 0 {
// 反预乘:dst = (预乘均值) / avgA
dst.Pix[o+0] = clamp8(sumR / sumW / avgA)
dst.Pix[o+1] = clamp8(sumG / sumW / avgA)
dst.Pix[o+2] = clamp8(sumB / sumW / avgA)
}
dst.Pix[o+3] = clamp8(avgA * 255)
}
}
return dst
}
func overlap(a0, a1, b0, b1 float64) float64 {
lo, hi := a0, a1
if b0 > lo {
lo = b0
}
if b1 < hi {
hi = b1
}
if hi <= lo {
return 0
}
return hi - lo
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
}
if v >= 255 {
return 255
}
return uint8(v + 0.5)
}
func math_Ceil(f float64) int {
i := int(f)
if float64(i) < f {
i++
}
if i < 0 {
return 0
}
return i
}
// ---------------------------------------------------------------- ICO 容器
type iconImage struct {
size int
data []byte
}
func buildICO(src image.Image) ([]byte, error) {
// 统一转成 NRGBA,缩放算法需要直通的 alpha
base := image.NewNRGBA(src.Bounds())
draw.Draw(base, base.Bounds(), src, src.Bounds().Min, draw.Src)
imgs := make([]iconImage, 0, len(sizes))
for _, s := range sizes {
img := base
if s != base.Rect.Dx() || s != base.Rect.Dy() {
img = resize(base, s, s)
}
var data []byte
var err error
if s >= pngMinSize {
data, err = encodePNG(img)
} else {
data = encodeDIB(img)
}
if err != nil {
return nil, fmt.Errorf("编码 %dx%d: %w", s, s, err)
}
imgs = append(imgs, iconImage{size: s, data: data})
}
var buf bytes.Buffer
// ICONDIR
binary.Write(&buf, binary.LittleEndian, uint16(0)) // reserved
binary.Write(&buf, binary.LittleEndian, uint16(1)) // type = icon
binary.Write(&buf, binary.LittleEndian, uint16(len(imgs)))
// ICONDIRENTRY 表。数据区紧跟在表后面,所以先算偏移。
offset := 6 + 16*len(imgs)
for _, im := range imgs {
dim := byte(im.size)
if im.size >= 256 {
dim = 0 // 0 表示 256
}
buf.WriteByte(dim)
buf.WriteByte(dim)
buf.WriteByte(0) // 调色板数,0 = 真彩色
buf.WriteByte(0) // reserved
binary.Write(&buf, binary.LittleEndian, uint16(1)) // planes
binary.Write(&buf, binary.LittleEndian, uint16(32)) // bitCount
binary.Write(&buf, binary.LittleEndian, uint32(len(im.data)))
binary.Write(&buf, binary.LittleEndian, uint32(offset))
offset += len(im.data)
}
for _, im := range imgs {
buf.Write(im.data)
}
return buf.Bytes(), nil
}
// encodeDIB 输出 ICO 里的 BMP 数据(BITMAPINFOHEADER + 像素 + AND 掩码,无文件头)
func encodeDIB(img *image.NRGBA) []byte {
w := img.Rect.Dx()
h := img.Rect.Dy()
// AND 掩码:1bpp,每行按 4 字节对齐
maskStride := ((w + 31) / 32) * 4
xorSize := w * h * 4
imgSize := 40 + xorSize + maskStride*h
buf := make([]byte, 0, imgSize)
// BITMAPINFOHEADER
hdr := make([]byte, 40)
binary.LittleEndian.PutUint32(hdr[0:], 40) // biSize
binary.LittleEndian.PutUint32(hdr[4:], uint32(w)) // biWidth
binary.LittleEndian.PutUint32(hdr[8:], uint32(h*2)) // biHeight = 图像高 + 掩码高
binary.LittleEndian.PutUint16(hdr[12:], 1) // biPlanes
binary.LittleEndian.PutUint16(hdr[14:], 32) // biBitCount
binary.LittleEndian.PutUint32(hdr[16:], 0) // biCompression = BI_RGB
binary.LittleEndian.PutUint32(hdr[20:], uint32(xorSize)) // biSizeImage
buf = append(buf, hdr...)
// 像素:BGRA、自下而上(ICO 的 DIB 和 BMP 一样从底行开始)
row := make([]byte, w*4)
for y := h - 1; y >= 0; y-- {
for x := 0; x < w; x++ {
c := img.NRGBAAt(x, y)
o := x * 4
row[o+0] = c.B
row[o+1] = c.G
row[o+2] = c.R
row[o+3] = c.A
}
buf = append(buf, row...)
}
// AND 掩码:32bpp 有 alpha 通道,掩码全 0(= 全不透明位)即可,
// 真正的透明信息已经在 alpha 里了。
buf = append(buf, make([]byte, maskStride*h)...)
return buf
}
func encodePNG(img *image.NRGBA) ([]byte, error) {
var b bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(&b, img); err != nil {
return nil, err
}
return b.Bytes(), nil
}
var _ = color.NRGBA{}