feat(工具箱): 添加“PNG 转 ICO 图标”工具

This commit is contained in:
2026-01-26 00:11:38 +08:00
parent ecadb6ce2a
commit c836302f99
3 changed files with 434 additions and 0 deletions

207
src/assets/js/png-to-ico.js Normal file
View File

@@ -0,0 +1,207 @@
/** PNG 转 ICO 图标,支持生成多尺寸 ICO */
class PNGtoICOConverter {
/**
* @description 将 PNG 图像转换为 ICO 格式
* @param {File|Blob|string} source PNG 文件、Blob 或 DataURL
* @param {number[]} sizes 需要生成的尺寸数组,默认 `[32, 128, 256]`
* @returns {Promise<Blob>} ICO 文件的 Blob 对象
*/
static async convert(source, sizes = [32, 128, 256]) {
try {
// 1. 加载 PNG 图像
let pngBlob = await this._preparePNG(source);
let imageBitmap = await createImageBitmap(pngBlob);
// 2. 创建各尺寸的图像
let imageDatas = await this._createMultiSizeImages(imageBitmap, sizes);
// 3. 生成 ICO 文件
let icoBlob = await this._generateICO(imageDatas);
return icoBlob;
} catch (error) {
throw new Error(`PNG 转 ICO 失败: ${error.message}`);
}
}
/**
* @description 准备 PNG 源数据
* @private
*/
static async _preparePNG(source) {
if (source instanceof File || source instanceof Blob) {
return source;
}
if (typeof source === 'string') {
if (source.startsWith('data:image/png')) {
const response = await fetch(source);
return await response.blob();
} else {
throw new Error('不支持的数据格式,仅支持 PNG 文件、Blob 或 DataURL');
}
}
throw new Error('无效的输入类型');
}
/**
* @description 创建多尺寸图像数据
* @private
*/
static async _createMultiSizeImages(imageBitmap, sizes) {
let results = [];
for (let size of sizes) {
// 按尺寸创建 Canvas
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
let ctx = canvas.getContext('2d');
// 设置高质量缩放
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 绘制缩放后的图像
ctx.drawImage(imageBitmap, 0, 0, size, size);
// 获取 PNG 数据
let pngBlob = await new Promise(resolve => {
canvas.toBlob(resolve, 'image/png');
});
let arrayBuffer = await pngBlob.arrayBuffer();
results.push({
size,
width: size,
height: size,
data: new Uint8Array(arrayBuffer),
pngSize: arrayBuffer.byteLength
});
}
return results;
}
/**
* @description 生成 ICO 文件
* @private
*/
static async _generateICO(imageDatas) {
// ICO 文件结构:
// 1. ICONDIR 头 (6 字节)
// 2. ICONDIRENTRY 数组 (每个 16 字节)
// 3. PNG 数据
let numImages = imageDatas.length;
// 计算总大小
let totalSize = 6; // ICONDIR 头
totalSize += numImages * 16; // ICONDIRENTRY 数组
totalSize += imageDatas.reduce((sum, img) => sum + img.pngSize, 0);
// 创建 ArrayBuffer
let buffer = new ArrayBuffer(totalSize);
let view = new DataView(buffer);
let offset = 0;
// 1. 写入 ICONDIR 头
view.setUint16(offset, 0, true); // Reserved (0)
offset += 2;
view.setUint16(offset, 1, true); // Type (1 for ICO)
offset += 2;
view.setUint16(offset, numImages, true); // Number of images
offset += 2;
// 2. 写入 ICONDIRENTRY 数组
let dataOffset = 6 + (numImages * 16);
for (let img of imageDatas) {
// 宽度和高度 (0 表示 256 像素)
view.setUint8(offset, img.width === 256 ? 0 : img.width);
offset += 1;
view.setUint8(offset, img.height === 256 ? 0 : img.height);
offset += 1;
view.setUint8(offset, 0); // Color palette (0 for no palette)
offset += 1;
view.setUint8(offset, 0); // Reserved
offset += 1;
view.setUint16(offset, 1, true); // Color planes
offset += 2;
view.setUint16(offset, 32, true); // Bits per pixel
offset += 2;
view.setUint32(offset, img.pngSize, true); // PNG data size
offset += 4;
view.setUint32(offset, dataOffset, true); // PNG data offset
offset += 4;
dataOffset += img.pngSize;
}
// 3. 写入 PNG 数据
for (let img of imageDatas) {
let uint8View = new Uint8Array(buffer, offset, img.pngSize);
uint8View.set(img.data);
offset += img.pngSize;
}
return new Blob([buffer], { type: 'image/x-icon' });
}
/**
* @description 下载 ICO 文件
* @param {Blob} icoBlob ICO 文件的 Blob 对象
* @param {string} filename 下载的文件名
*/
static download(icoBlob, filename = 'icon.ico') {
let url = URL.createObjectURL(icoBlob);
let a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
/**
* @description 将 PNG 转换为 ICO 并下载
* @param {File|Blob|string} source PNG 文件、Blob 或 DataURL
* @param {number[]} sizes 需要生成的尺寸数组
* @param {string} filename 下载的文件名
*/
export async function convertPNGtoICO(source, sizes, filename) {
try {
let blob = await PNGtoICOConverter.convert(source, sizes);
PNGtoICOConverter.download(blob, filename);
return { blob: blob, error: '', success: true };
} catch (error) {
console.error('转换失败:', error);
return { blob: null, error: error.message, success: false };
}
}

View File

@@ -54,6 +54,17 @@ export const toolList = [
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'png-to-ico',
component: 'Conversion/PngToIco',
title: 'PNG 转 ICO 图标',
iconClass: 'mdi mdi-image-outline',
desc: '将 PNG 图片转换为 ICO 图标。',
createdAt: '2026-01-26',
updatedAt: '2026-01-26',
version: '1',
enabled: true,
},
{ {
id: 'convert-timestamp', id: 'convert-timestamp',
component: 'Conversion/ConvertTimestamp', component: 'Conversion/ConvertTimestamp',

View File

@@ -0,0 +1,216 @@
<template>
<div class="tool-detail-page">
<!-- 文件选择 -->
<n-card size="small" title="文件选择">
<div class="file-description">
<p>支持格式PNGJPEGGIFBMP</p>
<p>建议图片大小小于 8MB</p>
<p>建议图片比例1:1正方形</p>
</div>
<n-upload
:max="1"
:on-before-upload="handleFileSelect"
:on-error="handleFileError"
:on-remove="handleFileRemove"
:show-file-list="false">
<n-button type="primary">选择文件</n-button>
</n-upload>
<div class="file-status">
<span>已选择文件</span>
<span v-if="formInfo.selectedFile">{{ formInfo.selectedFile.name }}</span>
<span v-else></span>
</div>
</n-card>
<!-- 分辨率配置 -->
<n-card size="small" title="分辨率配置">
<div class="section-description">
<p>一个 ICO 文件可以存储多种分辨率的图像</p>
</div>
<div class="selection-items">
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[16]"
>16px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[32]"
>32px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[48]"
>48px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[64]"
>64px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[128]"
>128px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[256]"
>256px 32 位深度支持</n-checkbox>
</p>
</div>
</n-card>
<!-- 位深度配置 -->
<n-card size="small" title="位深度配置">
<n-radio-group v-model:value="formInfo.bitDepth">
<p>
<n-radio :value="8">8 256 调色板</n-radio>
</p>
<p>
<n-radio :value="32">32 1670 万色 & 透明度</n-radio>
</p>
</n-radio-group>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
size="large"
type="primary"
@click="handleConvert"
>转换为 ICO 图标</n-button>
</n-flex>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex, NForm, NFormItem,
NCheckbox, NRadio, NRadioGroup, NUpload,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
$message, $notification,
} from '@/assets/js/naive-ui';
import {
convertPNGtoICO,
} from '@/assets/js/png-to-ico';
/** 数据 */
const formInfo = reactive({
/** 选择的文件 */
selectedFile: null,
/** 选择的分辨率 */
sizes: {
16: true,
32: true,
48: true,
64: false,
128: false,
256: false
},
/** 选择的位深度 */
bitDepth: 32,
});
/** 文件上传错误时的处理 */
function handleFileError() {
$message.error('文件上传失败,请重试');
}
/** 文件选择时的处理 */
function handleFileSelect(uploadData) {
formInfo.selectedFile = uploadData?.file?.file;
// 返回 false 阻止自动上传(只需要获取文件对象)
return false;
}
/** 文件移除时的处理 */
function handleFileRemove() {
formInfo.selectedFile = null;
}
/** 转换按钮点击处理 */
function handleConvert() {
if (!formInfo.selectedFile) {
$message.warning('请先选择文件');
return;
}
// 检查是否选择了至少一个分辨率
let selectedSizes = Object.keys(formInfo.sizes).filter((size) => {
return formInfo.sizes[size];
}).map((value) => {
return Number(value);
});
if (selectedSizes.length === 0) {
$message.warning('请至少选择一个分辨率');
return;
}
// 检查如果选择了 256 像素,是否位深度为 32 位
if (formInfo.sizes[256] && formInfo.bitDepth !== 32) {
$message.warning('256px 分辨率仅支持 32 位深度');
return;
}
convertPNGtoICO(
formInfo.selectedFile,
selectedSizes,
'icon.ico'
).then((result) => {
console.log('转换结果', result);
if (result.success) {
$message.success('文件转换成功');
} else {
$message.error('文件转换失败');
}
});
}
</script>
<style lang="less" scoped>
.file-description {
margin-bottom: 12px;
color: #666;
font-size: 14px;
line-height: 1.4;
}
.file-status {
margin-top: 12px;
font-size: 14px;
color: #666;
}
.section-description {
margin-bottom: 16px;
color: #666;
font-size: 14px;
line-height: 1.4;
}
:deep(.n-form-item) {
margin-bottom: 8px;
}
</style>