feat: 新增图片转 ICO 工具(纯前端多尺寸合成 + 体积控制)
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 7m32s
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 7m32s
This commit is contained in:
parent
b9369df4d6
commit
e65da3ac8f
@ -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.ts;Pillow 独立解码验证通过
|
||||||
|
|
||||||
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「统一工作约定」小节
|
||||||
|
|||||||
@ -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` | 时间戳转换 |
|
||||||
|
|||||||
@ -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
744
src/utils/ico.ts
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
/**
|
||||||
|
* ICO 图标编码工具库(纯浏览器端实现,零第三方依赖)。
|
||||||
|
*
|
||||||
|
* 支持把任意能被浏览器解码的图片(PNG / JPG / WebP / GIF / BMP / SVG 等)
|
||||||
|
* 合成为标准 Windows .ico 文件,并可通过「尺寸组合 + 编码方式 + 颜色量化 +
|
||||||
|
* 体积上限」把输出文件大小控制在目标范围内。
|
||||||
|
*
|
||||||
|
* ICO 文件格式(简要):
|
||||||
|
* ICONDIR(6B) + ICONDIRENTRY(16B × N) + N 段图像数据
|
||||||
|
* 每段图像数据可以是 BMP DIB(BITMAPINFOHEADER + XOR + AND mask),
|
||||||
|
* 也可以是一份完整的 PNG 文件流(Windows Vista 起支持,读取端靠魔数自动识别)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 合成 ICO 时的图像填充方式 */
|
||||||
|
export type IcoFitMode = 'cover' | 'contain' | 'stretch'
|
||||||
|
|
||||||
|
/** 条目编码方式:auto=小尺寸用 BMP、大尺寸用 PNG;bmp=全 BMP;png=全 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
|
||||||
|
/** 输出体积上限(KB),0 表示不限制 */
|
||||||
|
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 DIB(BITMAPINFOHEADER + 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) // biHeight:XOR + 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) // type:1 = 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')
|
||||||
|
}
|
||||||
471
src/views/image-to-ico/ImageToIcoView.vue
Normal file
471
src/views/image-to-ico/ImageToIcoView.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user