From e65da3ac8f37890fdf724c3daa276156ca2ade4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Mon, 31 Aug 2026 18:11:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=BD=AC=20ICO=20=E5=B7=A5=E5=85=B7=EF=BC=88=E7=BA=AF=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=A4=9A=E5=B0=BA=E5=AF=B8=E5=90=88=E6=88=90=20+=20?= =?UTF-8?q?=E4=BD=93=E7=A7=AF=E6=8E=A7=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/CHANGELOG.md | 2 + README.md | 3 +- src/router/tools.ts | 9 + src/utils/ico.ts | 744 ++++++++++++++++++++++ src/views/image-to-ico/ImageToIcoView.vue | 471 ++++++++++++++ 5 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 src/utils/ico.ts create mode 100644 src/views/image-to-ico/ImageToIcoView.vue diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index fc6ab49..f1ee56a 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -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「统一工作约定」小节 diff --git a/README.md b/README.md index b5df6f4..6b975b8 100644 --- a/README.md +++ b/README.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` | 时间戳转换 | diff --git a/src/router/tools.ts b/src/router/tools.ts index d8bf6b7..9542b3f 100644 --- a/src/router/tools.ts +++ b/src/router/tools.ts @@ -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 格式化', diff --git a/src/utils/ico.ts b/src/utils/ico.ts new file mode 100644 index 0000000..ade8a4e --- /dev/null +++ b/src/utils/ico.ts @@ -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 { + 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() + 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() + 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 { + 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 { + 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(/ { + 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, + 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 { + 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() + 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') +} diff --git a/src/views/image-to-ico/ImageToIcoView.vue b/src/views/image-to-ico/ImageToIcoView.vue new file mode 100644 index 0000000..3091d95 --- /dev/null +++ b/src/views/image-to-ico/ImageToIcoView.vue @@ -0,0 +1,471 @@ + + + + +