266 lines
8.6 KiB
HTML
266 lines
8.6 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>PNG 转 ICO</title>
|
||
<style>
|
||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 24px; line-height: 1.5; }
|
||
.row { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
|
||
.card { border: 1px solid #ddd; border-radius: 10px; padding: 16px; max-width: 860px; }
|
||
.sizes { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 12px; }
|
||
label { user-select: none; }
|
||
button { padding: 10px 14px; border-radius: 10px; border: 1px solid #ddd; cursor: pointer; }
|
||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||
.hint { color: #555; font-size: 14px; }
|
||
canvas { border: 1px dashed #ccc; border-radius: 8px; }
|
||
.preview { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; }
|
||
.preview-item { text-align: center; font-size: 12px; color: #555; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h2>PNG 转化 ICO</h2>
|
||
|
||
<div class="card">
|
||
<div class="row">
|
||
<input id="file" type="file" accept="image/png" />
|
||
<button id="convert" disabled>转换并下载 .ico</button>
|
||
</div>
|
||
|
||
<div class="hint" style="margin-top:10px;">
|
||
默认生成:16/32/48/64/128/256。<br>
|
||
建议上传至少 256×256 的 PNG,避免小图强行放大后发糊。
|
||
</div>
|
||
|
||
<div style="margin-top:14px;">
|
||
<div><strong>选择尺寸:</strong></div>
|
||
<div class="sizes" id="sizes"></div>
|
||
<div class="row">
|
||
<label>缩放方式:
|
||
<select id="fit">
|
||
<option value="contain" selected>Contain(完整显示,留透明边)</option>
|
||
<option value="cover">Cover(铺满裁切)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
背景:
|
||
<select id="bg">
|
||
<option value="transparent" selected>透明</option>
|
||
<option value="white">白色</option>
|
||
<option value="black">黑色</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview" id="preview"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// 常用 icon 尺寸
|
||
const DEFAULT_SIZES = [16, 32, 48, 64, 128, 256];
|
||
const sizesWrap = document.getElementById('sizes');
|
||
const previewWrap = document.getElementById('preview');
|
||
const fileInput = document.getElementById('file');
|
||
const convertBtn = document.getElementById('convert');
|
||
|
||
// 生成尺寸复选框
|
||
for (const s of DEFAULT_SIZES) {
|
||
const id = `sz_${s}`;
|
||
const label = document.createElement('label');
|
||
label.innerHTML = `<input type="checkbox" id="${id}" value="${s}" checked> ${s}×${s}`;
|
||
sizesWrap.appendChild(label);
|
||
}
|
||
|
||
let currentImageBitmap = null;
|
||
|
||
fileInput.addEventListener('change', async () => {
|
||
previewWrap.innerHTML = '';
|
||
currentImageBitmap = null;
|
||
convertBtn.disabled = true;
|
||
|
||
const file = fileInput.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (file.type !== 'image/png') {
|
||
alert('请上传 PNG 文件');
|
||
return;
|
||
}
|
||
|
||
const bitmap = await createImageBitmap(file);
|
||
currentImageBitmap = bitmap;
|
||
|
||
// 生成简单预览(只预览 64 和 128)
|
||
for (const s of [64, 128]) {
|
||
const canvas = await renderToCanvas(bitmap, s, getFit(), getBg());
|
||
const item = document.createElement('div');
|
||
item.className = 'preview-item';
|
||
item.appendChild(canvas);
|
||
const cap = document.createElement('div');
|
||
cap.textContent = `${s}×${s}`;
|
||
item.appendChild(cap);
|
||
previewWrap.appendChild(item);
|
||
}
|
||
|
||
convertBtn.disabled = false;
|
||
});
|
||
|
||
convertBtn.addEventListener('click', async () => {
|
||
try {
|
||
if (!currentImageBitmap) return;
|
||
|
||
const sizes = getSelectedSizes();
|
||
if (sizes.length === 0) {
|
||
alert('请至少选择一个尺寸');
|
||
return;
|
||
}
|
||
|
||
convertBtn.disabled = true;
|
||
convertBtn.textContent = '转换中...';
|
||
|
||
// 1) 生成每个尺寸的 PNG ArrayBuffer
|
||
const pngBuffers = [];
|
||
for (const s of sizes) {
|
||
const canvas = await renderToCanvas(currentImageBitmap, s, getFit(), getBg());
|
||
const ab = await canvasToPngArrayBuffer(canvas);
|
||
pngBuffers.push({ size: s, buffer: ab });
|
||
}
|
||
|
||
// 2) 打包成 ICO(每张图像使用 PNG payload)
|
||
const icoBytes = buildIcoFromPngBuffers(pngBuffers);
|
||
|
||
// 3) 下载
|
||
const blob = new Blob([icoBytes], { type: 'image/x-icon' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'favicon.ico';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('转换失败:' + (e?.message || e));
|
||
} finally {
|
||
convertBtn.disabled = false;
|
||
convertBtn.textContent = '转换并下载 .ico';
|
||
}
|
||
});
|
||
|
||
function getSelectedSizes() {
|
||
const cbs = sizesWrap.querySelectorAll('input[type="checkbox"]');
|
||
const sizes = [];
|
||
cbs.forEach(cb => { if (cb.checked) sizes.push(parseInt(cb.value, 10)); });
|
||
// 从小到大排序(无硬性要求,但更直观)
|
||
sizes.sort((a,b) => a - b);
|
||
return sizes;
|
||
}
|
||
|
||
function getFit() {
|
||
return document.getElementById('fit').value;
|
||
}
|
||
function getBg() {
|
||
return document.getElementById('bg').value;
|
||
}
|
||
|
||
async function renderToCanvas(bitmap, size, fit = 'contain', bg = 'transparent') {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// 背景
|
||
if (bg !== 'transparent') {
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, size, size);
|
||
} else {
|
||
ctx.clearRect(0, 0, size, size);
|
||
}
|
||
|
||
const iw = bitmap.width, ih = bitmap.height;
|
||
|
||
// 计算绘制区域
|
||
let dw, dh, dx, dy;
|
||
const scaleContain = Math.min(size / iw, size / ih);
|
||
const scaleCover = Math.max(size / iw, size / ih);
|
||
const scale = (fit === 'cover') ? scaleCover : scaleContain;
|
||
|
||
dw = Math.round(iw * scale);
|
||
dh = Math.round(ih * scale);
|
||
dx = Math.round((size - dw) / 2);
|
||
dy = Math.round((size - dh) / 2);
|
||
|
||
// 更好的缩放质量
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
|
||
ctx.drawImage(bitmap, dx, dy, dw, dh);
|
||
|
||
// cover 模式下如果超出画布,drawImage 已经裁切了
|
||
return canvas;
|
||
}
|
||
|
||
function canvasToPngArrayBuffer(canvas) {
|
||
return new Promise((resolve, reject) => {
|
||
canvas.toBlob(async (blob) => {
|
||
try {
|
||
if (!blob) return reject(new Error('canvas.toBlob() 返回空'));
|
||
const ab = await blob.arrayBuffer();
|
||
resolve(ab);
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
}, 'image/png');
|
||
});
|
||
}
|
||
|
||
// 按 ICO 格式打包:ICONDIR(6) + ICONDIRENTRY(n*16) + PNG data...
|
||
// 目录项字段(常见解释):width/height(1B, 0 表示 256), colorCount(1B), reserved(1B),
|
||
// planes(2B), bitCount(2B), bytesInRes(4B), imageOffset(4B)
|
||
function buildIcoFromPngBuffers(pngs) {
|
||
const count = pngs.length;
|
||
|
||
const headerSize = 6;
|
||
const entrySize = 16;
|
||
const dirSize = headerSize + count * entrySize;
|
||
|
||
// 计算总大小
|
||
let totalSize = dirSize;
|
||
for (const p of pngs) totalSize += p.buffer.byteLength;
|
||
|
||
const out = new Uint8Array(totalSize);
|
||
const dv = new DataView(out.buffer);
|
||
|
||
// ICONDIR
|
||
dv.setUint16(0, 0, true); // reserved
|
||
dv.setUint16(2, 1, true); // type = 1 (icon)
|
||
dv.setUint16(4, count, true); // count
|
||
|
||
// ICONDIRENTRYs
|
||
let dataOffset = dirSize;
|
||
for (let i = 0; i < count; i++) {
|
||
const { size, buffer } = pngs[i];
|
||
const entryOff = headerSize + i * entrySize;
|
||
|
||
dv.setUint8(entryOff + 0, size >= 256 ? 0 : size); // width
|
||
dv.setUint8(entryOff + 1, size >= 256 ? 0 : size); // height
|
||
dv.setUint8(entryOff + 2, 0); // colorCount
|
||
dv.setUint8(entryOff + 3, 0); // reserved
|
||
dv.setUint16(entryOff + 4, 1, true); // planes (常用 1)
|
||
dv.setUint16(entryOff + 6, 32, true); // bitCount (常用 32)
|
||
dv.setUint32(entryOff + 8, buffer.byteLength, true); // bytesInRes
|
||
dv.setUint32(entryOff + 12, dataOffset, true); // imageOffset
|
||
|
||
// 拷贝 PNG 数据
|
||
out.set(new Uint8Array(buffer), dataOffset);
|
||
dataOffset += buffer.byteLength;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|