first commit
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 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{}
|
||||
Reference in New Issue
Block a user