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 变更记录
|
||||
> 倒序:最新在上。格式: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 | 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 响应式适配(移动端为抽屉导航)
|
||||
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
|
||||
- 每个工具独立路由、独立本地历史记录(localStorage)
|
||||
@ -56,6 +56,7 @@ pnpm preview
|
||||
| `/` | 首页(工具卡片导航) |
|
||||
| `/image-compress` | 图片压缩 |
|
||||
| `/image-cropper` | 图片裁剪 |
|
||||
| `/image-to-ico` | 图片转 ICO |
|
||||
| `/qrcode` | 二维码生成 / 解析 |
|
||||
| `/json-format` | JSON 格式化 |
|
||||
| `/timestamp` | 时间戳转换 |
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ImageIcon,
|
||||
FileImageIcon,
|
||||
FrameIcon,
|
||||
QrcodeIcon,
|
||||
CodeIcon,
|
||||
@ -110,6 +111,14 @@ export const tools: ToolItem[] = [
|
||||
category: 'work',
|
||||
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',
|
||||
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