update: 2026-03-28 20:59

This commit is contained in:
2026-03-28 20:59:52 +08:00
parent e21d58e603
commit 1c81d4e6ea
611 changed files with 27847 additions and 65061 deletions

View File

@@ -0,0 +1,265 @@
<!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>