Skip to content

实现 Favicon / App Icon 生成 工具

分类:developer | 标签:favicon、icon、PWA 上传图片批量生成 favicon 与多尺寸图标、PWA manifest

2.1 功能说明

上传图片批量生成 favicon 与多尺寸图标、PWA manifest

2.1.1 使用指南

功能说明

上传一张源图(建议使用正方形、分辨率不低于 512×512 的 PNG),工具会在浏览器本地用 Canvas 把它缩放成网站与应用常用的全部图标尺寸,并可导出多尺寸 favicon.ico 与 PWA manifest 片段。整个过程不上传任何文件。

尺寸说明

16 / 32 / 48:浏览器标签页、书签栏、Windows 任务栏使用的传统 favicon 尺寸。

64 / 128 / 256:桌面端高分屏 favicon 与 Chrome 应用图标。

72 / 96 / 144 / 192 / 384 / 512:Android 与 PWA manifest 标准图标尺寸,其中 192 与 512 是 PWA 安装的必备项。

152 / 167 / 180:iOS apple-touch-icon 尺寸,分别对应 iPad、iPad Pro 和 iPhone。

缩放与裁切

等比留白(contain):完整保留源图内容,不足部分用背景色或透明填充,适合非正方形源图。

等比裁切(cover):铺满整个方形画布,超出部分被裁掉,适合背景满幅的图。

拉伸填满(stretch):强行拉伸到正方形,可能变形,仅在特殊需要时使用。

还可以设置背景色(或勾选透明背景)与圆角比例,圆角以画布边长的百分比计算,50% 即为圆形图标。

关于 favicon.ico

生成的 .ico 采用 PNG-in-ICO 结构:6 字节 ICONDIR 文件头,加上每个尺寸 16 字节的目录项,再拼接各尺寸的 PNG 原始数据。这是 Vista 之后 Windows 与所有现代浏览器都支持的标准做法,体积比传统 BMP-in-ICO 小很多。需要注意的是,极老的环境(例如 Windows XP 自带资源管理器、IE6)只识别 BMP 编码的 ICO,无法显示这种文件;如果必须兼容这类环境,请另行使用专门的 BMP 编码工具。ICO 规范中单边最大为 256 像素,256 在目录项里以 0 表示,工具已按规范处理。

下载方式

为了保持零第三方依赖,工具不提供 ZIP 打包。每个尺寸都有独立的 PNG 下载按钮,也可以点击「依次下载全部 PNG」连续触发下载,浏览器可能会询问是否允许多文件下载,请选择允许。manifest.json 与 HTML 引用片段可直接复制或下载为文件。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • pickFile()
  • loadImageFile()
  • setSource()
  • onPick()
  • onDrop()
  • clearPreviews()
  • reset()
  • renderSize()
  • roundRectPath()
  • renderAll()
  • downloadBlob()
  • downloadPng()
  • sleep()
  • downloadAllPng()
  • downloadText()
  • copyText()
  • blobToArrayBuffer()
  • canvasToPngBytes()
  • buildIcoBlob()
  • downloadIco()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, watch, markRaw, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const SIZES = [
  { size: 16, usage: '浏览器标签页' },
  { size: 32, usage: '书签栏 / 高分屏' },
  { size: 48, usage: 'Windows 站点图标' },
  { size: 64, usage: '桌面快捷方式' },
  { size: 72, usage: 'Android 低密度' },
  { size: 96, usage: 'Android 中密度' },
  { size: 128, usage: 'Chrome 应用' },
  { size: 144, usage: 'Android 高密度' },
  { size: 152, usage: 'iPad 触摸图标' },
  { size: 167, usage: 'iPad Pro 图标' },
  { size: 180, usage: 'iPhone 触摸图标' },
  { size: 192, usage: 'PWA 必备尺寸' },
  { size: 256, usage: 'Windows 大图标' },
  { size: 384, usage: 'Android 超高密度' },
  { size: 512, usage: 'PWA 启动图标' }
]

const PWA_SIZES = [72, 96, 128, 144, 152, 192, 384, 512]

const fileInput = ref(null)
const source = ref(null)
const errorMsg = ref('')
const tipMsg = ref('')

const fitMode = ref('contain')
const bgColor = ref('#ffffff')
const transparentBg = ref(true)
const radiusPct = ref(0)
const paddingPct = ref(0)

const previews = ref([])
const icoCandidates = [16, 32, 48, 64, 128, 256]
const icoSizes = ref([16, 32, 48, 64, 128, 256])
const icoBuilding = ref(false)

const appName = ref('My App')

const pickFile = () => fileInput.value && fileInput.value.click()

function loadImageFile(file) {
  return new Promise((resolve, reject) => {
    const objectURL = URL.createObjectURL(file)
    const img = new Image()
    img.onload = () => {
      resolve({
        name: file.name || '未命名图片',
        url: objectURL,
        img: markRaw(img),
        width: img.naturalWidth || img.width,
        height: img.naturalHeight || img.height
      })
    }
    img.onerror = () => {
      URL.revokeObjectURL(objectURL)
      reject(new Error('无法解析图片:' + (file.name || '未命名')))
    }
    img.src = objectURL
  })
}

async function setSource(file) {
  errorMsg.value = ''
  tipMsg.value = ''
  if (!file || file.type.indexOf('image/') !== 0) {
    errorMsg.value = '请选择一个有效的图片文件'
    return
  }
  try {
    const item = await loadImageFile(file)
    if (source.value) URL.revokeObjectURL(source.value.url)
    clearPreviews()
    source.value = item
    const dot = item.name.lastIndexOf('.')
    appName.value = dot > 0 ? item.name.slice(0, dot) : item.name
    renderAll()
  } catch (e) {
    errorMsg.value = e.message
  }
}

async function onPick(evt) {
  await setSource(evt.target.files && evt.target.files[0])
  evt.target.value = ''
}

async function onDrop(evt) {
  const files = evt.dataTransfer && evt.dataTransfer.files
  await setSource(files && files[0])
}

function clearPreviews() {
  previews.value = []
}

function reset() {
  if (source.value) URL.revokeObjectURL(source.value.url)
  source.value = null
  clearPreviews()
  errorMsg.value = ''
  tipMsg.value = ''
}

/** 把源图按当前配置渲染到指定边长的方形 canvas */
function renderSize(size) {
  const canvas = document.createElement('canvas')
  canvas.width = size
  canvas.height = size
  const ctx = canvas.getContext('2d')
  const radius = (Math.min(radiusPct.value, 50) / 100) * size

  if (radius > 0) {
    ctx.beginPath()
    roundRectPath(ctx, 0, 0, size, size, radius)
    ctx.clip()
  }

  if (!transparentBg.value) {
    ctx.fillStyle = bgColor.value || '#ffffff'
    ctx.fillRect(0, 0, size, size)
  }

  const pad = Math.round((paddingPct.value / 100) * size)
  const box = Math.max(1, size - pad * 2)
  const src = source.value.img
  const sw = source.value.width
  const sh = source.value.height

  ctx.imageSmoothingEnabled = true
  ctx.imageSmoothingQuality = 'high'

  if (fitMode.value === 'stretch') {
    ctx.drawImage(src, pad, pad, box, box)
  } else if (fitMode.value === 'cover') {
    const scale = Math.max(box / sw, box / sh)
    const dw = sw * scale
    const dh = sh * scale
    ctx.drawImage(src, pad + (box - dw) / 2, pad + (box - dh) / 2, dw, dh)
  } else {
    const scale = Math.min(box / sw, box / sh)
    const dw = sw * scale
    const dh = sh * scale
    ctx.drawImage(src, pad + (box - dw) / 2, pad + (box - dh) / 2, dw, dh)
  }
  return canvas
}

function roundRectPath(ctx, x, y, w, h, r) {
  const rr = Math.min(r, w / 2, h / 2)
  ctx.moveTo(x + rr, y)
  ctx.lineTo(x + w - rr, y)
  ctx.arcTo(x + w, y, x + w, y + rr, rr)
  ctx.lineTo(x + w, y + h - rr)
  ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr)
  ctx.lineTo(x + rr, y + h)
  ctx.arcTo(x, y + h, x, y + h - rr, rr)
  ctx.lineTo(x, y + rr)
  ctx.arcTo(x, y, x + rr, y, rr)
  ctx.closePath()
}

function renderAll() {
  if (!source.value) return
  previews.value = SIZES.map((item) => {
    const canvas = renderSize(item.size)
    return {
      size: item.size,
      usage: item.usage,
      display: Math.min(item.size, 96),
      url: canvas.toDataURL('image/png')
    }
  })
}

let renderTimer = null
watch([fitMode, bgColor, transparentBg, radiusPct, paddingPct], () => {
  if (!source.value) return
  if (renderTimer) clearTimeout(renderTimer)
  renderTimer = setTimeout(renderAll, 120)
})

/* ---------------- 下载 ---------------- */

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 2000)
}

function downloadPng(size) {
  const canvas = renderSize(size)
  canvas.toBlob((blob) => {
    if (blob) downloadBlob(blob, 'icon-' + size + 'x' + size + '.png')
  }, 'image/png')
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

async function downloadAllPng() {
  for (const item of SIZES) {
    downloadPng(item.size)
    await sleep(320)
  }
  tipMsg.value = '已依次触发 ' + SIZES.length + ' 个 PNG 下载,请在浏览器下载列表中查看'
}

function downloadText(text, filename) {
  downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), filename)
}

async function copyText(text) {
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(text)
    } else {
      const ta = document.createElement('textarea')
      ta.value = text
      ta.style.position = 'fixed'
      ta.style.opacity = '0'
      document.body.appendChild(ta)
      ta.select()
      document.execCommand('copy')
      document.body.removeChild(ta)
    }
    tipMsg.value = '已复制到剪贴板'
    setTimeout(() => { tipMsg.value = '' }, 2000)
  } catch (e) {
    errorMsg.value = '复制失败,请手动选中文本复制'
  }
}

/* ---------------- ICO 编码 ---------------- */

function blobToArrayBuffer(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = () => reject(new Error('读取 PNG 数据失败'))
    reader.readAsArrayBuffer(blob)
  })
}

function canvasToPngBytes(canvas) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(async (blob) => {
      if (!blob) {
        reject(new Error('生成 PNG 失败'))
        return
      }
      try {
        resolve(new Uint8Array(await blobToArrayBuffer(blob)))
      } catch (e) {
        reject(e)
      }
    }, 'image/png')
  })
}

/**
 * PNG-in-ICO 编码
 * 结构:ICONDIR(6 字节) + N × ICONDIRENTRY(16 字节) + N 段 PNG 原始数据
 */
async function buildIcoBlob(sizes) {
  const ordered = sizes.slice().sort((a, b) => a - b)
  const entries = []
  for (const size of ordered) {
    const bytes = await canvasToPngBytes(renderSize(size))
    entries.push({ size, bytes })
  }

  const headerSize = 6 + 16 * entries.length
  const totalSize = entries.reduce((sum, e) => sum + e.bytes.length, headerSize)
  const out = new Uint8Array(totalSize)
  const view = new DataView(out.buffer)

  // ICONDIR
  view.setUint16(0, 0, true) // reserved
  view.setUint16(2, 1, true) // type = 1 表示图标
  view.setUint16(4, entries.length, true)

  let offset = headerSize
  entries.forEach((entry, index) => {
    const base = 6 + index * 16
    // 256 在规范中以 0 表示
    const dim = entry.size >= 256 ? 0 : entry.size
    out[base] = dim // width
    out[base + 1] = dim // height
    out[base + 2] = 0 // 调色板颜色数,真彩色为 0
    out[base + 3] = 0 // reserved
    view.setUint16(base + 4, 1, true) // color planes
    view.setUint16(base + 6, 32, true) // bits per pixel
    view.setUint32(base + 8, entry.bytes.length, true) // 数据长度
    view.setUint32(base + 12, offset, true) // 数据偏移
    out.set(entry.bytes, offset)
    offset += entry.bytes.length
  })

  return new Blob([out], { type: 'image/x-icon' })
}

async function downloadIco() {
  if (!source.value || !icoSizes.value.length) return
  icoBuilding.value = true
  errorMsg.value = ''
  try {
    const blob = await buildIcoBlob(icoSizes.value)
    downloadBlob(blob, 'favicon.ico')
    tipMsg.value = 'favicon.ico 已生成,包含 ' + icoSizes.value.length + ' 个尺寸'
  } catch (e) {
    errorMsg.value = 'ICO 生成失败:' + e.message
  } finally {
    icoBuilding.value = false
  }
}

/* ---------------- 文本片段 ---------------- */

const manifestText = computed(() => {
  const icons = PWA_SIZES.map((s) => ({
    src: '/icons/icon-' + s + 'x' + s + '.png',
    sizes: s + 'x' + s,
    type: 'image/png',
    purpose: s >= 192 ? 'any maskable' : 'any'
  }))
  const manifest = {
    name: appName.value || 'My App',
    short_name: (appName.value || 'My App').slice(0, 12),
    start_url: '/',
    scope: '/',
    display: 'standalone',
    background_color: transparentBg.value ? '#ffffff' : bgColor.value,
    theme_color: transparentBg.value ? '#ffffff' : bgColor.value,
    icons
  }
  return JSON.stringify(manifest, null, 2)
})

const htmlSnippet = computed(() => {
  const lines = [
    '<link rel="icon" href="/favicon.ico" sizes="any">',
    '<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16x16.png">',
    '<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png">',
    '<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192x192.png">',
    '<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">',
    '<link rel="apple-touch-icon" sizes="167x167" href="/icons/icon-167x167.png">',
    '<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png">',
    '<link rel="manifest" href="/manifest.json">',
    '<meta name="theme-color" content="' + (transparentBg.value ? '#ffffff' : bgColor.value) + '">'
  ]
  return lines.join('\n')
})

onBeforeUnmount(() => {
  if (renderTimer) clearTimeout(renderTimer)
  if (source.value) URL.revokeObjectURL(source.value.url)
})

2.3 效果截图

Favicon / App Icon 生成 效果截图

工具访问地址:https://www.i91tools.com/tools/icon-generator