feat: 新增图片转 ICO 工具(纯前端多尺寸合成 + 体积控制)
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 7m32s

This commit is contained in:
夏犀麟 2026-08-31 18:11:08 +08:00
parent b9369df4d6
commit e65da3ac8f
5 changed files with 1228 additions and 1 deletions

View File

@ -1,6 +1,8 @@
# tool.xpcool.com 变更记录 # tool.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-31 | CHG | 新增「图片转 ICO」工具src/utils/ico.ts纯前端自实现 PNG/BMP DIB/ICO 编码 + 中位切分量化 + 自适应滤波 + 体积上限自动降级src/views/image-to-ico/ImageToIcoView.vue尺寸组合/填充/编码方式/量化/体积限制/预览下载/历史),路由注册于 src/router/tools.tsPillow 独立解码验证通过
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引 2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引
2026-08-26 | CHG | 同步三铁律(中文注释/做记录/中文优先)至 AGENTS.md「统一工作约定」小节 2026-08-26 | CHG | 同步三铁律(中文注释/做记录/中文优先)至 AGENTS.md「统一工作约定」小节

View File

@ -4,7 +4,7 @@
## 功能特性 ## 功能特性
- 27 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别、UUID 生成、正则测试、命名转换、色值转换、单位换算、YAML ↔ JSON、字数统计、人民币大写、简繁转换、日期计算、密码生成、HTTP 状态码速查 - 27 个实用工具:图片压缩/裁剪、图片转 ICO、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别、UUID 生成、正则测试、命名转换、色值转换、单位换算、YAML ↔ JSON、字数统计、人民币大写、简繁转换、日期计算、密码生成、HTTP 状态码速查
- 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航) - 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失 - 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
- 每个工具独立路由、独立本地历史记录localStorage - 每个工具独立路由、独立本地历史记录localStorage
@ -56,6 +56,7 @@ pnpm preview
| `/` | 首页(工具卡片导航) | | `/` | 首页(工具卡片导航) |
| `/image-compress` | 图片压缩 | | `/image-compress` | 图片压缩 |
| `/image-cropper` | 图片裁剪 | | `/image-cropper` | 图片裁剪 |
| `/image-to-ico` | 图片转 ICO |
| `/qrcode` | 二维码生成 / 解析 | | `/qrcode` | 二维码生成 / 解析 |
| `/json-format` | JSON 格式化 | | `/json-format` | JSON 格式化 |
| `/timestamp` | 时间戳转换 | | `/timestamp` | 时间戳转换 |

View File

@ -1,6 +1,7 @@
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ImageIcon, ImageIcon,
FileImageIcon,
FrameIcon, FrameIcon,
QrcodeIcon, QrcodeIcon,
CodeIcon, CodeIcon,
@ -110,6 +111,14 @@ export const tools: ToolItem[] = [
category: 'work', category: 'work',
component: () => import('@/views/image-compress/ImageCompressView.vue'), component: () => import('@/views/image-compress/ImageCompressView.vue'),
}, },
{
path: 'image-to-ico',
name: '图片转 ICO',
desc: 'PNG/JPG/WebP/SVG 转图标,多尺寸合成并控制输出体积',
icon: FileImageIcon,
category: 'work',
component: () => import('@/views/image-to-ico/ImageToIcoView.vue'),
},
{ {
path: 'json-format', path: 'json-format',
name: 'JSON 格式化', name: 'JSON 格式化',

744
src/utils/ico.ts Normal file
View File

@ -0,0 +1,744 @@
/**
* ICO
*
* PNG / JPG / WebP / GIF / BMP / SVG
* Windows .ico + + +
*
*
* ICO
* ICONDIR(6B) + ICONDIRENTRY(16B × N) + N
* BMP DIBBITMAPINFOHEADER + XOR + AND mask
* PNG Windows Vista
*/
/** 合成 ICO 时的图像填充方式 */
export type IcoFitMode = 'cover' | 'contain' | 'stretch'
/** 条目编码方式auto=小尺寸用 BMP、大尺寸用 PNGbmp=全 BMPpng=全 PNG */
export type IcoEncodeMode = 'auto' | 'bmp' | 'png'
/** ICO 构建参数 */
export interface IcoBuildParams {
/** 需要生成的图标尺寸正方边长1~256内部会去重并升序排列 */
sizes: number[]
/** 填充方式 */
fit: IcoFitMode
/** 编码方式 */
mode: IcoEncodeMode
/** 是否启用颜色量化(有损,最多 256 色),可显著减小体积 */
quantize: boolean
/** 量化最大颜色数2~256 */
maxColors: number
/** 输出体积上限KB0 表示不限制 */
limitKb: number
}
/** 单个条目的编码结果描述(用于结果展示) */
export interface IcoEntryMeta {
/** 图标尺寸 */
size: number
/** 该条目实际使用的编码格式 */
format: 'PNG' | 'BMP'
/** 该条目数据字节数 */
bytes: number
}
/** ICO 构建结果 */
export interface IcoBuildResult {
/** 生成的 .ico 文件 */
blob: Blob
/** 各条目明细(升序) */
entries: IcoEntryMeta[]
/** 因体积上限被自动移除的尺寸 */
droppedSizes: number[]
/** 是否实际启用了颜色量化 */
quantized: boolean
/** 各尺寸的预览图DataURL供页面回显 */
previews: Array<{ size: number; url: string }>
}
/** ICO 内部单条目:尺寸 + 编码后的图像数据 */
interface IcoImage {
size: number
data: Uint8Array
}
/* ============================================================
* CRC32 / zlib
* ============================================================ */
/** CRC32 查表PNG chunk 校验用) */
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
return table
})()
function crc32(buf: Uint8Array): number {
let c = 0xffffffff
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
/** 拼接多段字节数组 */
function concatBytes(parts: Uint8Array[]): Uint8Array {
let total = 0
for (const p of parts) total += p.length
const out = new Uint8Array(total)
let pos = 0
for (const p of parts) {
out.set(p, pos)
pos += p.length
}
return out
}
/** Adler32 校验zlib 尾部) */
function adler32(data: Uint8Array): number {
let a = 1
let b = 0
for (let i = 0; i < data.length; i++) {
a = (a + data[i]) % 65521
b = (b + a) % 65521
}
return (((b << 16) | a) >>> 0) as number
}
/**
* stored zlib
* CompressionStream
*/
function zlibStored(data: Uint8Array): Uint8Array {
const parts: Uint8Array[] = [new Uint8Array([0x78, 0x01])]
let pos = 0
let remaining = data.length
// 即使数据为空也要输出一个 final 空块,否则流不合法
do {
const size = Math.min(remaining, 65535)
const header = new Uint8Array(5)
header[0] = remaining - size === 0 ? 1 : 0 // BFINAL
header[1] = size & 0xff // LEN小端
header[2] = (size >> 8) & 0xff
header[3] = ~size & 0xff // NLEN = ~LEN
header[4] = (~size >> 8) & 0xff
parts.push(header, data.subarray(pos, pos + size))
pos += size
remaining -= size
} while (remaining > 0)
const sum = new Uint8Array(4)
new DataView(sum.buffer).setUint32(0, adler32(data))
parts.push(sum)
return concatBytes(parts)
}
/**
* zlib 使 CompressionStream
* PNG
*/
async function deflate(data: Uint8Array): Promise<Uint8Array> {
const Ctor = (globalThis as { CompressionStream?: new (fmt: string) => TransformStream }).CompressionStream
if (typeof Ctor === 'function') {
try {
const stream = new Blob([data as unknown as BlobPart]).stream().pipeThrough(new Ctor('deflate'))
const buf = await new Response(stream).arrayBuffer()
if (buf.byteLength) return new Uint8Array(buf)
} catch {
// 浏览器策略或类型转换异常时走兜底逻辑
}
}
return zlibStored(data)
}
/* ============================================================
* PNG
* ============================================================ */
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/** 组装一个 PNG chunk长度(4) + 类型(4) + 数据 + CRC(4) */
function pngChunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(data.length + 12)
const dv = new DataView(out.buffer)
dv.setUint32(0, data.length)
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
out.set(data, 8)
dv.setUint32(data.length + 8, crc32(out.subarray(4, 8 + data.length)))
return out
}
/** PNG 规范中的 Paeth 预测器 */
function paeth(a: number, b: number, c: number): number {
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
if (pa <= pb && pa <= pc) return a
return pb <= pc ? b : c
}
/**
* None/Sub/Up/Paeth
* deflate ICO
* @param raw raw.length / height
* 8 width × bpp
*/
function filterRows(raw: Uint8Array, width: number, height: number, bpp: number): Uint8Array {
const stride = raw.length / height
const out = new Uint8Array((stride + 1) * height)
if (!stride || !height || !width) return out
// 四种候选的缓冲区与 SAD 累加器,复用避免每行重复分配
const candidates = [0, 1, 2, 4].map(() => new Uint8Array(stride))
const sads = new Float64Array(4)
for (let y = 0; y < height; y++) {
const cur = y * stride
const prev = cur - stride
for (let k = 0; k < 4; k++) sads[k] = 0
for (let x = 0; x < stride; x++) {
const v = raw[cur + x]
const a = x >= bpp ? raw[cur + x - bpp] : 0 // 左
const b = y > 0 ? raw[prev + x] : 0 // 上
const c = y > 0 && x >= bpp ? raw[prev + x - bpp] : 0 // 左上
candidates[0][x] = v
candidates[1][x] = (v - a) & 0xff
candidates[2][x] = (v - b) & 0xff
candidates[3][x] = (v - paeth(a, b, c)) & 0xff
// SAD把字节当作有符号数累加绝对值越小越易被压缩
for (let k = 0; k < 4; k++) sads[k] += candidates[k][x] < 128 ? candidates[k][x] : 256 - candidates[k][x]
}
let bestIdx = 0
for (let k = 1; k < 4; k++) if (sads[k] < sads[bestIdx]) bestIdx = k
out[y * (stride + 1)] = [0, 1, 2, 4][bestIdx]
out.set(candidates[bestIdx], y * (stride + 1) + 1)
}
return out
}
/** 把逐像素的调色板索引按位深打包成 PNG 扫描行(每行字节数按位补齐) */
function packIndices(indices: Uint8Array, width: number, height: number, bitDepth: number): Uint8Array {
const rowBytes = Math.ceil((width * bitDepth) / 8)
const out = new Uint8Array(rowBytes * height)
let p = 0
for (let y = 0; y < height; y++) {
const rowStart = p
let bitBuf = 0
let bitCnt = 0
for (let x = 0; x < width; x++) {
bitBuf = (bitBuf << bitDepth) | indices[y * width + x]
bitCnt += bitDepth
while (bitCnt >= 8) {
const shift = bitCnt - 8
out[p++] = (bitBuf >> shift) & 0xff
bitCnt -= 8
bitBuf &= (1 << bitCnt) - 1
}
}
if (bitCnt > 0) out[p++] = (bitBuf << (8 - bitCnt)) & 0xff
p = rowStart + rowBytes // 跳到下一行起点(补齐行尾未用位)
}
return out
}
/** 量化结果调色板RGB、每色 alpha、逐像素索引 */
interface Quantized {
palette: Uint8Array
alphas: Uint8Array
indices: Uint8Array
}
/**
* median cut maxColors
* 0 RGB
* alpha使
*/
function medianCut(rgba: Uint8ClampedArray, maxColors: number): Quantized {
const total = rgba.length / 4
/** 参与聚类的像素下标(排除全透明像素) */
const opaque: number[] = []
for (let i = 0; i < total; i++) if (rgba[i * 4 + 3] !== 0) opaque.push(i)
/** 一个颜色簇:像素下标集合 + RGB 包围盒 */
interface Box {
items: number[]
rMin: number
rMax: number
gMin: number
gMax: number
bMin: number
bMax: number
}
function makeBox(items: number[]): Box {
let rMin = 255
let rMax = 0
let gMin = 255
let gMax = 0
let bMin = 255
let bMax = 0
for (const i of items) {
const r = rgba[i * 4]
const g = rgba[i * 4 + 1]
const b = rgba[i * 4 + 2]
if (r < rMin) rMin = r
if (r > rMax) rMax = r
if (g < gMin) gMin = g
if (g > gMax) gMax = g
if (b < bMin) bMin = b
if (b > bMax) bMax = b
}
return { items, rMin, rMax, gMin, gMax, bMin, bMax }
}
const boxes: Box[] = []
// 存在全透明像素时,索引 0 预留给透明色,其余簇最多 maxColors - 1 个
const hasTransparent = opaque.length < total
const maxBoxes = Math.max(1, (hasTransparent ? maxColors - 1 : maxColors) || 1)
if (opaque.length) boxes.push(makeBox(opaque))
while (boxes.length < maxBoxes) {
// 每轮挑「跨度 × 像素数」最大的簇继续切分,收益最高
let target = -1
let best = -1
for (let i = 0; i < boxes.length; i++) {
const b = boxes[i]
if (b.items.length < 2) continue
const span = Math.max(b.rMax - b.rMin, b.gMax - b.gMin, b.bMax - b.bMin)
const score = span * b.items.length
if (score > best) {
best = score
target = i
}
}
if (target < 0) break // 所有簇都已不可再分
const box = boxes[target]
const dr = box.rMax - box.rMin
const dg = box.gMax - box.gMin
const db = box.bMax - box.bMin
const channel = dr >= dg && dr >= db ? 0 : dg >= db ? 1 : 2
box.items.sort((x, y) => rgba[x * 4 + channel] - rgba[y * 4 + channel])
const mid = box.items.length >> 1
boxes.splice(target, 1, makeBox(box.items.slice(0, mid)), makeBox(box.items.slice(mid)))
}
// 生成调色板:索引 0 为透明色,其后为各簇平均色
const palette: number[] = []
const alphas: number[] = []
if (hasTransparent) {
palette.push(0, 0, 0)
alphas.push(0)
}
for (const box of boxes) {
let r = 0
let g = 0
let b = 0
let a = 0
for (const i of box.items) {
r += rgba[i * 4]
g += rgba[i * 4 + 1]
b += rgba[i * 4 + 2]
a += rgba[i * 4 + 3]
}
const n = box.items.length || 1
palette.push(Math.round(r / n), Math.round(g / n), Math.round(b / n))
alphas.push(Math.round(a / n))
}
// 逐像素匹配最近颜色;相同 RGB 只计算一次并缓存,避免 O(n×k) 全量开销
const colorCount = alphas.length
const startIdx = hasTransparent ? 1 : 0
const indices = new Uint8Array(total)
const cache = new Map<number, number>()
for (let i = 0; i < total; i++) {
if (hasTransparent && rgba[i * 4 + 3] === 0) {
indices[i] = 0
continue
}
const key = (rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]
let idx = cache.get(key)
if (idx === undefined) {
let bestDist = Infinity
let bestIdx = startIdx
for (let c = startIdx; c < colorCount; c++) {
const dr = rgba[i * 4] - palette[c * 3]
const dg = rgba[i * 4 + 1] - palette[c * 3 + 1]
const db = rgba[i * 4 + 2] - palette[c * 3 + 2]
const dist = dr * dr + dg * dg + db * db
if (dist < bestDist) {
bestDist = dist
bestIdx = c
}
}
idx = bestIdx
cache.set(key, idx)
}
indices[i] = idx
}
return { palette: new Uint8Array(palette), alphas: new Uint8Array(alphas), indices }
}
/** 统计唯一颜色(含 alpha颜色数不超过 max 时返回精确调色板(完全无损) */
function exactPalette(rgba: Uint8ClampedArray, max: number): Quantized | null {
const total = rgba.length / 4
const seen = new Map<number, number>()
for (let i = 0; i < total; i++) {
if (seen.size > max) return null
const key = (((rgba[i * 4] << 24) | (rgba[i * 4 + 1] << 16) | (rgba[i * 4 + 2] << 8) | rgba[i * 4 + 3]) >>> 0) as number
if (!seen.has(key)) seen.set(key, seen.size)
}
const palette = new Uint8Array(seen.size * 3)
const alphas = new Uint8Array(seen.size)
const indices = new Uint8Array(total)
for (const [key, idx] of seen) {
palette[idx * 3] = (key >>> 24) & 0xff
palette[idx * 3 + 1] = (key >>> 16) & 0xff
palette[idx * 3 + 2] = (key >>> 8) & 0xff
alphas[idx] = key & 0xff
}
for (let i = 0; i < total; i++) {
const key = (((rgba[i * 4] << 24) | (rgba[i * 4 + 1] << 16) | (rgba[i * 4 + 2] << 8) | rgba[i * 4 + 3]) >>> 0) as number
indices[i] = seen.get(key) as number
}
return { palette, alphas, indices }
}
/**
* ImageData PNG 便
* - 256 使PLTE + tRNS 1/2/4/8 bit
* -
* - 使 RGB RGBA
*/
export async function encodePng(
image: ImageData,
options: { quantize?: boolean; maxColors?: number } = {},
): Promise<Uint8Array> {
const { width, height, data } = image
const quantize = options.quantize ?? false
const maxColors = Math.min(256, Math.max(2, options.maxColors ?? 256))
let palette: Uint8Array | null = null
let alphas: Uint8Array | null = null
let raw: Uint8Array
let colorType: number
let bitDepth = 8
let bpp: number
// 量化模式:颜色数本就在上限内时直接用精确调色板(无损且更小),超出才走有损聚类
const quantized = quantize ? (exactPalette(data, maxColors) ?? medianCut(data, maxColors)) : exactPalette(data, 256)
if (quantized) {
palette = quantized.palette
alphas = quantized.alphas
// 按颜色数选择最小可行位深2/4/16/256 色分别对应 1/2/4/8 bit
const count = alphas.length
bitDepth = count <= 2 ? 1 : count <= 4 ? 2 : count <= 16 ? 4 : 8
raw = packIndices(quantized.indices, width, height, bitDepth)
colorType = 3
bpp = 1
} else {
// 真彩色:统计是否存在透明像素,全不透明时省掉 alpha 通道
let hasAlpha = false
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) {
hasAlpha = true
break
}
}
colorType = hasAlpha ? 6 : 2
bpp = hasAlpha ? 4 : 3
raw = new Uint8Array(width * height * bpp)
let p = 0
for (let i = 0; i < data.length; i += 4) {
raw[p++] = data[i]
raw[p++] = data[i + 1]
raw[p++] = data[i + 2]
if (hasAlpha) raw[p++] = data[i + 3]
}
}
const ihdr = new Uint8Array(13)
const dv = new DataView(ihdr.buffer)
dv.setUint32(0, width)
dv.setUint32(4, height)
ihdr[8] = bitDepth
ihdr[9] = colorType
// 压缩方式 0、滤波方式 0、非隔行默认值即为 0
const parts: Uint8Array[] = [PNG_SIGNATURE, pngChunk('IHDR', ihdr)]
if (palette) parts.push(pngChunk('PLTE', palette))
if (alphas) {
// tRNS只有存在半透明色时才输出长度截断到最后一个非 255 的 alpha
let last = -1
for (let i = 0; i < alphas.length; i++) if (alphas[i] < 255) last = i
if (last >= 0) parts.push(pngChunk('tRNS', alphas.subarray(0, last + 1)))
}
const idat = await deflate(filterRows(raw, width, height, bpp))
parts.push(pngChunk('IDAT', idat))
parts.push(pngChunk('IEND', new Uint8Array(0)))
return concatBytes(parts)
}
/* ============================================================
* BMP DIB ICO
* ============================================================ */
/**
* 32bpp BMP DIBBITMAPINFOHEADER + XOR + AND
* ICO DIB 2 1bpp AND
* BGRA bottom-up
*/
export function encodeBmpDib(image: ImageData): Uint8Array {
const { width, height, data } = image
const xorSize = width * height * 4
// AND 遮罩每行按 4 字节对齐
const maskRowBytes = ((width + 31) >> 5) * 4
const andSize = maskRowBytes * height
const out = new Uint8Array(40 + xorSize + andSize)
const dv = new DataView(out.buffer)
dv.setUint32(0, 40, true) // biSize
dv.setInt32(4, width, true) // biWidth
dv.setInt32(8, height * 2, true) // biHeightXOR + AND
dv.setUint16(12, 1, true) // biPlanes
dv.setUint16(14, 32, true) // biBitCount
dv.setUint32(16, 0, true) // biCompression = BI_RGB
dv.setUint32(20, xorSize + andSize, true) // biSizeImage
// XOR 位图自下而上、BGRA
let p = 40
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
const s = (y * width + x) * 4
out[p++] = data[s + 2]
out[p++] = data[s + 1]
out[p++] = data[s]
out[p++] = data[s + 3]
}
}
// AND 遮罩1bpp透明alpha < 128置 1行尾补零对齐
for (let y = height - 1; y >= 0; y--) {
const rowStart = p
let byte = 0
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] < 128) byte |= 0x80 >> (x & 7)
if ((x & 7) === 7) {
out[p++] = byte
byte = 0
}
}
if (width & 7) out[p++] = byte
p = rowStart + maskRowBytes
}
return out
}
/* ============================================================
* ICO
* ============================================================ */
/** 把若干条目封装成标准 .ico 文件 */
export function encodeIco(images: IcoImage[]): Blob {
const count = images.length
const dirSize = 16 * count
let dataSize = 0
for (const img of images) dataSize += img.data.length
const out = new Uint8Array(6 + dirSize + dataSize)
const dv = new DataView(out.buffer)
dv.setUint16(0, 0, true) // reserved
dv.setUint16(2, 1, true) // type1 = icon
dv.setUint16(4, count, true)
let offset = 6 + dirSize
let p = 6
for (const img of images) {
// 宽高字段为 1 字节256 用 0 表示
out[p] = img.size >= 256 ? 0 : img.size
out[p + 1] = out[p]
out[p + 2] = 0 // 颜色数0 表示 ≥ 256 色
out[p + 3] = 0 // reserved
dv.setUint16(p + 4, 1, true) // 位平面
dv.setUint16(p + 6, 32, true) // 位深
dv.setUint32(p + 8, img.data.length, true) // 数据字节数
dv.setUint32(p + 12, offset, true) // 数据偏移
out.set(img.data, offset)
offset += img.data.length
p += 16
}
return new Blob([out as unknown as BlobPart], { type: 'image/x-icon' })
}
/* ============================================================
* +
* ============================================================ */
/** 创建 2D 上下文并开启高质量平滑 */
function createCtx(w: number, h: number): CanvasRenderingContext2D {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) throw new Error('当前浏览器不支持 Canvas 2D')
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
return ctx
}
/**
* size × size ImageData
* 16px 齿
*/
export function renderToImageData(
source: HTMLImageElement,
size: number,
fit: IcoFitMode = 'cover',
): ImageData {
const srcW = source.naturalWidth || source.width
const srcH = source.naturalHeight || source.height
if (!srcW || !srcH) throw new Error('无法读取图片尺寸,请更换图片重试')
// 超大源图先等比收进 2048px避免一次性占用过多 canvas 内存
const capScale = Math.min(1, 2048 / Math.max(srcW, srcH))
let curW = Math.max(1, Math.round(srcW * capScale))
let curH = Math.max(1, Math.round(srcH * capScale))
let ctx = createCtx(curW, curH)
ctx.drawImage(source, 0, 0, curW, curH)
while (Math.max(curW, curH) > size * 2) {
const nextW = Math.max(size, Math.round(curW / 2))
const nextH = Math.max(size, Math.round(curH / 2))
const next = createCtx(nextW, nextH)
next.drawImage(ctx.canvas, 0, 0, nextW, nextH)
ctx = next
curW = nextW
curH = nextH
}
// 最终绘制:按填充方式算出绘制区域
const finalCtx = createCtx(size, size)
if (fit === 'stretch') {
finalCtx.drawImage(ctx.canvas, 0, 0, size, size)
} else {
const scale = fit === 'cover' ? Math.max(size / curW, size / curH) : Math.min(size / curW, size / curH)
const dw = curW * scale
const dh = curH * scale
finalCtx.drawImage(ctx.canvas, (size - dw) / 2, (size - dh) / 2, dw, dh)
}
return finalCtx.getImageData(0, 0, size, size)
}
/**
* SVG
* viewBox 512
*/
export async function normalizeSvg(file: File): Promise<Blob> {
const text = await file.text()
const head = text.slice(0, 2000)
if (/\swidth\s*=/.test(head) && /\sheight\s*=/.test(head)) return file
const viewBox = /viewBox\s*=\s*["']\s*[-\d.]+\s*[, ]\s*[-\d.]+\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/.exec(head)
let w = 512
let h = 512
if (viewBox) {
w = Math.round(Number(viewBox[1])) || 512
h = Math.round(Number(viewBox[2])) || 512
}
const scale = Math.max(1, 512 / Math.max(w, h))
const patched = text.replace(/<svg\b/i, `<svg width="${Math.round(w * scale)}" height="${Math.round(h * scale)}"`)
return new Blob([patched], { type: 'image/svg+xml' })
}
/** 加载图片源Blob/File为 HTMLImageElement */
export function loadImage(source: Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(source)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('图片解码失败:文件可能已损坏,或浏览器不支持该格式'))
}
img.src = url
})
}
/* ============================================================
* ICO
* ============================================================ */
/** 按当前策略编码一批尺寸,返回条目数据与明细 */
async function encodeEntries(
sizes: number[],
rendered: Map<number, ImageData>,
mode: IcoEncodeMode,
quantize: boolean,
maxColors: number,
): Promise<{ images: IcoImage[]; meta: IcoEntryMeta[] }> {
const images: IcoImage[] = []
const meta: IcoEntryMeta[] = []
for (const size of sizes) {
const image = rendered.get(size)
if (!image) continue
// auto 模式128 及以上的条目改用 PNG避免 BMP 条目体积失控
const usePng = mode === 'png' || (mode === 'auto' && size >= 128)
const data = usePng ? await encodePng(image, { quantize, maxColors }) : encodeBmpDib(image)
images.push({ size, data })
meta.push({ size, format: usePng ? 'PNG' : 'BMP', bytes: data.length })
}
return { images, meta }
}
/** 计算 ICO 文件总字节数6 字节头 + 16 字节目录 × N + 各条目数据) */
function icoTotalBytes(meta: IcoEntryMeta[]): number {
return 6 + 16 * meta.length + meta.reduce((s, m) => s + m.bytes, 0)
}
/**
* ICO
*
*/
export async function buildIco(source: HTMLImageElement, params: IcoBuildParams): Promise<IcoBuildResult> {
const sizes = Array.from(new Set(params.sizes.filter((s) => s >= 1 && s <= 256))).sort((a, b) => a - b)
if (!sizes.length) throw new Error('请至少选择一个图标尺寸')
// 先渲染所有尺寸并缓存,后续降级重试时无需重复渲染
const rendered = new Map<number, ImageData>()
for (const size of sizes) rendered.set(size, renderToImageData(source, size, params.fit))
const limitBytes = params.limitKb > 0 ? params.limitKb * 1024 : 0
let quantize = params.quantize
let active = [...sizes]
let encoded = await encodeEntries(active, rendered, params.mode, quantize, params.maxColors)
// 超过体积上限时逐步降级,直到满足或无法再降
while (limitBytes && icoTotalBytes(encoded.meta) > limitBytes) {
if (!quantize) {
quantize = true // 优先用有损量化换取体积
} else if (active.length > 1) {
active = active.slice(0, -1) // 移除最大的尺寸
} else {
break // 只剩最小尺寸且已量化,无法继续压缩
}
encoded = await encodeEntries(active, rendered, params.mode, quantize, params.maxColors)
}
const blob = encodeIco(encoded.images)
const previews = active.map((size) => ({ size, url: imageDataToDataUrl(rendered.get(size) as ImageData) }))
return {
blob,
entries: encoded.meta,
droppedSizes: sizes.filter((s) => !active.includes(s)),
quantized: quantize,
previews,
}
}
/** 把 ImageData 转成 PNG DataURL用于页面预览回显 */
function imageDataToDataUrl(image: ImageData): string {
const ctx = createCtx(image.width, image.height)
ctx.putImageData(image, 0, 0)
return ctx.canvas.toDataURL('image/png')
}

View File

@ -0,0 +1,471 @@
<template>
<div class="page-wrap">
<h2 class="page-title">图片转 ICO</h2>
<p class="page-desc">
PNG / JPG / WebP / GIF / BMP / SVG 等图片转成 Windows 图标.ico自由组合尺寸控制输出体积全程在浏览器本地完成图片不会上传服务器
</p>
<t-card class="tool-section" title="上传图片">
<UploadDrop
v-model:files="files"
accept="image/*"
:multiple="false"
tip="支持 PNG / JPG / WebP / GIF / BMP / SVG 等常见格式"
/>
<p v-if="sourceInfo" class="source-info">
{{ sourceInfo.name }} · {{ sourceInfo.width }} × {{ sourceInfo.height }} · {{ formatBytes(sourceInfo.size) }}
</p>
</t-card>
<t-card class="tool-section" title="转换设置">
<!-- 图标尺寸预设 + 勾选 + 自定义 -->
<div class="form-row form-row-top">
<span class="form-label">图标尺寸</span>
<div class="size-presets">
<t-button v-for="p in sizePresets" :key="p.label" size="small" variant="outline" @click="applyPreset(p)">
{{ p.label }}
</t-button>
</div>
</div>
<div class="form-row">
<span class="form-label"></span>
<t-checkbox-group v-model="sizes">
<t-checkbox v-for="s in sizeOptions" :key="s" :value="s">{{ s }}×{{ s }}</t-checkbox>
</t-checkbox-group>
</div>
<div class="form-row">
<span class="form-label"></span>
<t-input-number v-model="customSize" :min="8" :max="256" :step="8" theme="normal" class="custom-input" />
<t-button variant="outline" size="small" @click="addCustomSize">添加自定义尺寸</t-button>
<span class="form-suffix">尺寸越多单尺寸越大ICO 体积越大上限 256</span>
</div>
<!-- 非正方形图片的适配方式 -->
<div class="form-row">
<span class="form-label">填充方式</span>
<t-radio-group v-model="fit" variant="default-filled">
<t-radio-button value="cover">裁剪居中</t-radio-button>
<t-radio-button value="contain">完整留白</t-radio-button>
<t-radio-button value="stretch">拉伸铺满</t-radio-button>
</t-radio-group>
</div>
<!-- 编码方式直接决定体积与兼容性 -->
<div class="form-row">
<span class="form-label">编码方式</span>
<t-select v-model="mode" :options="modeOptions" class="form-select" />
<span class="form-suffix">{{ modeHint }}</span>
</div>
<!-- 颜色量化有损用于进一步压体积 -->
<div class="form-row">
<span class="form-label">颜色量化</span>
<t-switch v-model="quantize" />
<span class="form-suffix">有损压缩到 256 色以内体积可再降 50%+logo / 纯色图标几乎无损</span>
</div>
<div v-if="quantize" class="form-row">
<span class="form-label">颜色数量</span>
<t-slider v-model="maxColors" class="form-slider" :min="8" :max="256" :step="8" />
<span class="form-value">{{ maxColors }} </span>
</div>
<!-- 体积上限超出后自动降级先量化再从大到小删尺寸 -->
<div class="form-row">
<span class="form-label">限制体积</span>
<t-switch v-model="limitEnabled" />
<span class="form-suffix">超出上限时自动降级先启用量化再移除最大的尺寸</span>
</div>
<div v-if="limitEnabled" class="form-row">
<span class="form-label">上限</span>
<t-input-number v-model="limitKb" :min="1" :max="1024" :step="8" theme="normal" />
<span class="form-suffix">KB</span>
</div>
<div class="form-actions">
<t-button theme="primary" :loading="busy" @click="convert">{{ busy ? '生成中' : '生成 ICO' }}</t-button>
<t-button variant="outline" :disabled="!result && !files.length" @click="resetAll">清空</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="转换结果">
<div class="result-head">
<div class="ico-preview checker">
<img v-if="icoUrl" :src="icoUrl" alt="ICO 预览" />
</div>
<div class="result-summary">
<p class="summary-line">
<strong>{{ result.entries.length }}</strong> 个尺寸条目文件大小
<strong class="size-strong">{{ formatBytes(result.blob.size) }}</strong>
</p>
<p class="summary-line">
<t-tag v-for="e in result.entries" :key="e.size" size="small" theme="primary" variant="light" class="entry-tag">
{{ e.size }} {{ e.format }} {{ formatBytes(e.bytes) }}
</t-tag>
</p>
<p v-if="result.quantized && !quantize" class="summary-tip">已自动启用颜色量化以满足体积上限</p>
<p v-if="result.droppedSizes.length" class="summary-tip">
为满足体积上限已移除尺寸{{ result.droppedSizes.join(' / ') }}
</p>
</div>
<div class="result-actions">
<t-button theme="primary" @click="downloadIco">下载 ICO</t-button>
</div>
</div>
<t-divider />
<p class="grid-title">各尺寸预览点击右下角可单独下载 PNG</p>
<div class="preview-grid">
<div v-for="p in result.previews" :key="p.size" class="preview-cell">
<div class="preview-box checker">
<img :src="p.url" :alt="`${p.size}px`" :style="{ width: Math.min(p.size, 96) + 'px' }" />
</div>
<div class="preview-meta">
<span class="preview-size">{{ p.size }}×{{ p.size }}</span>
<t-button size="small" variant="text" theme="primary" @click="downloadPng(p)">PNG</t-button>
</div>
</div>
</div>
</t-card>
<HistoryPanel :items="history.items" @select="onHistorySelect" @remove="history.remove" @clear="history.clear" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { downloadBlob } from '@/composables/useDownload'
import { dataUrlToBlob, formatBytes } from '@/utils'
import {
buildIco,
loadImage,
normalizeSvg,
type IcoBuildResult,
type IcoEncodeMode,
type IcoFitMode,
} from '@/utils/ico'
/** 可选的图标边长Windows / favicon 常用尺寸) */
const sizeOptions = [16, 24, 32, 48, 64, 96, 128, 256]
/** 常用尺寸组合,一键套用 */
const sizePresets = [
{ label: 'Windows 全套', sizes: [16, 24, 32, 48, 64, 96, 128, 256] },
{ label: '常用组合', sizes: [16, 32, 48, 64, 128, 256] },
{ label: '网站 favicon', sizes: [16, 32, 48] },
{ label: '仅 256', sizes: [256] },
]
/** 编码方式选项 */
const modeOptions = [
{ label: '自动(推荐)', value: 'auto' },
{ label: '全部 BMP兼容老系统', value: 'bmp' },
{ label: '全部 PNG体积最小', value: 'png' },
]
const files = ref<File[]>([])
const sizes = ref<number[]>([16, 32, 48, 64, 256])
const customSize = ref(128)
const fit = ref<IcoFitMode>('cover')
const mode = ref<IcoEncodeMode>('auto')
const quantize = ref(false)
const maxColors = ref(256)
const limitEnabled = ref(false)
const limitKb = ref(64)
const busy = ref(false)
const error = ref('')
const result = ref<IcoBuildResult | null>(null)
/** ICO 文件的对象 URL用于页面内预览切换结果时释放旧的 */
const icoUrl = ref('')
/** 源图信息(文件名、原始宽高、大小) */
const sourceInfo = ref<{ name: string; width: number; height: number; size: number } | null>(null)
/** 历史记录:存「文件名 | 参数摘要」,点击可恢复转换参数 */
const history = useHistory('image-to-ico')
/** 编码方式对应的说明文案 */
const modeHint = computed(() => {
if (mode.value === 'bmp') return 'BMP 兼容性最好,但 128/256 尺寸体积会明显偏大'
if (mode.value === 'png') return 'PNG 体积最小,需 Windows Vista 及以上系统'
return '自动128 及以上用 PNG 压缩,小尺寸用 BMP兼顾体积与兼容性'
})
function applyPreset(preset: { sizes: number[] }) {
sizes.value = [...preset.sizes]
}
/** 添加自定义尺寸(去重、升序维护) */
function addCustomSize() {
const s = Math.min(256, Math.max(8, Math.round(customSize.value)))
if (!sizes.value.includes(s)) sizes.value = [...sizes.value, s].sort((a, b) => a - b)
}
/** 主流程:解码源图 → 合成 ICO → 生成预览与历史 */
async function convert() {
if (!files.value.length) {
error.value = '请先上传一张图片'
return
}
if (!sizes.value.length) {
error.value = '请至少选择一个图标尺寸'
return
}
busy.value = true
error.value = ''
clearResult()
try {
const file = files.value[0]
// SVG
const source = file.type === 'image/svg+xml' ? await normalizeSvg(file) : file
const img = await loadImage(source)
sourceInfo.value = {
name: file.name,
width: img.naturalWidth,
height: img.naturalHeight,
size: file.size,
}
const built = await buildIco(img, {
sizes: sizes.value,
fit: fit.value,
mode: mode.value,
quantize: quantize.value,
maxColors: maxColors.value,
limitKb: limitEnabled.value ? limitKb.value : 0,
})
result.value = built
icoUrl.value = URL.createObjectURL(built.blob)
// localStorage
history.add(
`${file.name} | sizes=${sizes.value.join(',')} | fit=${fit.value} | mode=${mode.value} | q=${
quantize.value ? maxColors.value : 'off'
} | limit=${limitEnabled.value ? limitKb.value : 0}`,
)
} catch (e) {
error.value = e instanceof Error ? e.message : '转换失败,请更换图片重试'
} finally {
busy.value = false
}
}
function downloadIco() {
if (!result.value) return
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
downloadBlob(result.value.blob, `${base}.ico`)
}
/** 单独下载某个尺寸的 PNG做 favicon / 应用图标时常用) */
function downloadPng(p: { size: number; url: string }) {
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
downloadBlob(dataUrlToBlob(p.url), `${base}_${p.size}x${p.size}.png`)
}
/** 点击历史:解析摘要串恢复转换参数 */
function onHistorySelect(text: string) {
const sMatch = /sizes=([\d,]+)/.exec(text)
if (sMatch) {
sizes.value = sMatch[1]
.split(',')
.map(Number)
.filter((n) => n > 0 && n <= 256)
}
const fMatch = /fit=(\w+)/.exec(text)
if (fMatch) fit.value = fMatch[1] as IcoFitMode
const mMatch = /mode=(\w+)/.exec(text)
if (mMatch) mode.value = mMatch[1] as IcoEncodeMode
const qMatch = /q=(\d+|off)/.exec(text)
if (qMatch) {
quantize.value = qMatch[1] !== 'off'
if (quantize.value) maxColors.value = Number(qMatch[1])
}
const lMatch = /limit=(\d+)/.exec(text)
if (lMatch) {
limitEnabled.value = Number(lMatch[1]) > 0
if (limitEnabled.value) limitKb.value = Number(lMatch[1])
}
}
function clearResult() {
if (icoUrl.value) URL.revokeObjectURL(icoUrl.value)
icoUrl.value = ''
result.value = null
}
function resetAll() {
clearResult()
files.value = []
sourceInfo.value = null
error.value = ''
}
onBeforeUnmount(() => {
if (icoUrl.value) URL.revokeObjectURL(icoUrl.value)
})
</script>
<style scoped>
.form-label {
width: 72px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.form-row-top {
margin-top: 0;
}
.size-presets {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.custom-input {
width: 140px;
}
.form-select {
width: 220px;
}
.form-slider {
flex: 1;
max-width: 260px;
}
.form-value {
width: 60px;
font-size: 13px;
color: var(--td-brand-color);
font-weight: 600;
}
.form-suffix {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 18px;
flex-wrap: wrap;
}
.source-info {
margin: 10px 0 0;
font-size: 13px;
color: var(--td-text-color-secondary);
}
/* 透明棋盘格背景:用于展示带 alpha 通道的图标 */
.checker {
background-color: var(--td-bg-color-secondarycontainer);
background-image:
linear-gradient(45deg, rgba(127, 127, 127, 0.25) 25%, transparent 25%, transparent 75%, rgba(127, 127, 127, 0.25) 75%),
linear-gradient(45deg, rgba(127, 127, 127, 0.25) 25%, transparent 25%, transparent 75%, rgba(127, 127, 127, 0.25) 75%);
background-position:
0 0,
6px 6px;
background-size: 12px 12px;
}
.result-head {
display: flex;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.ico-preview {
width: 96px;
height: 96px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ico-preview img {
width: 64px;
height: 64px;
}
.result-summary {
flex: 1;
min-width: 220px;
}
.summary-line {
margin: 0 0 6px;
font-size: 13px;
color: var(--td-text-color-secondary);
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.size-strong {
color: var(--td-success-color);
}
.entry-tag {
margin: 0;
}
.summary-tip {
margin: 4px 0 0;
font-size: 12px;
color: var(--td-warning-color);
}
.result-actions {
flex-shrink: 0;
}
.grid-title {
margin: 0 0 12px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.preview-cell {
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
padding: 10px;
background: var(--td-bg-color-secondarycontainer);
}
.preview-box {
height: 104px;
border-radius: 6px;
border: 1px solid var(--td-border-level-1-color);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 小尺寸图标按原像素展示,避免浏览器缩放导致模糊 */
.preview-box img {
image-rendering: pixelated;
max-width: 96px;
max-height: 96px;
}
.preview-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.preview-size {
font-size: 12px;
color: var(--td-text-color-secondary);
}
@media (max-width: 767px) {
.result-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
}
</style>