feat(工具箱): 添加“PNG 转 ICO 图标”工具
This commit is contained in:
207
src/assets/js/png-to-ico.js
Normal file
207
src/assets/js/png-to-ico.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
216
src/views/ToolboxView/Conversion/PngToIco.vue
Normal file
216
src/views/ToolboxView/Conversion/PngToIco.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tool-detail-page">
|
||||||
|
|
||||||
|
<!-- 文件选择 -->
|
||||||
|
<n-card size="small" title="文件选择">
|
||||||
|
<div class="file-description">
|
||||||
|
<p>支持格式:PNG、JPEG、GIF、BMP</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>
|
||||||
Reference in New Issue
Block a user