深度解析

ICO 格式解碼:檔案結構、標頭位元組與存續原因

koboshiCo-founder
·10 分鐘閱讀
ICO 格式解碼:檔案結構、標頭位元組與存續原因
概述

ICO 是容器,不是影像編解碼器。它誕生於 1985 年的 Windows 1.0,將同一圖示的多種解析度打包進單一檔案,讓作業系統在執行期間挑選最適合的尺寸。這裡是完整的技術拆解——從六個位元組的檔頭到 SVG favicon 為何至今仍未將它淘汰。

用十六進位編輯器打開 ICO 檔案,前六個位元組長這樣:

00 00 01 00 03 00

這就是完整的檔案標頭。00 00 是 Reserved 欄位——永遠為零。01 00 是 Type 欄位——1 代表這是圖示檔(游標檔案用 2)。03 00 是 Count 欄位——little-endian 表示 3,意思是這個 ICO 包含三張獨立影像。六個位元組描述一個容器,裡面裝了三份獨立的影像檔案。檔案其餘部分是這些影像的詮釋資料,接著才是影像資料本身。

ICO 既不是壓縮演算法,也不是色彩空間,而是一個容器——當今仍在使用的最小、最原始的容器格式,每天都在每一台 Windows 電腦和每一個主流瀏覽器上默默運作。

ICO 的起源

Microsoft 在 1985 年的 Windows 1.0 中推出 ICO。當年的 PC 生態系統根本沒有標準化影像檔的概念。沒有 JPEG、沒有 PNG、沒有 GIF。點陣圖資料只是原始像素陣列,每個程式各自處理儲存方式。Microsoft 需要一種方式為程式、資料夾和系統 UI 元素提供圖示。需求很單純:

  • 一個圖示一個檔案,不論作業系統需要多少種尺寸
  • 執行期間快速查詢——作業系統不必解碼影像就能知道尺寸
  • 在僅有 256 KB RAM 的系統上保持極小記憶體佔用

解決方案是目錄結構。檔案以一個標頭開頭,說明裡面有多少張影像。接著是一個項目陣列,每個項目描述一張影像的寬度、高度、色彩深度以及在檔案中的偏移量。作業系統讀取目錄,挑出符合當前顯示脈絡的項目,跳轉到該偏移量,然後渲染點陣圖。

這個設計比 ZIP 早兩年,比 GIF 早兩年,比 JPEG 早七年。ICO 在檔案系統變得有趣之前,就已經是一個微型檔案系統。

ICO 存在的根本理由

圖示不是單一影像,而是一組涵蓋多種尺寸的影像組合。Windows 在檔案清單中以 16 x 16 渲染同一個圖示,在桌面上以 32 x 32,在檔案總管詳細資料窗格中以 48 x 48,在「超大圖示」檢視中以 256 x 256。200% 縮放的高 DPI 螢幕需要從 64 x 64 來源渲染 32 x 32 圖示。作業系統根據當前脈絡挑選最適合的尺寸,而這個選擇必須在瞬間完成。

如果圖示以獨立 PNG 檔案儲存,作業系統必須開啟多個檔案、分別解碼、快取結果。使用 ICO,作業系統只開一個檔案,讀一個 16 位元組的目錄項目,就直接跳到點陣圖資料。目錄讓格式具備自描述能力,詮釋資料不需要解碼器。

對網站圖示而言,道理也完全一樣。當瀏覽器請求 /favicon.ico 時,它拿到的是一個包含所有可能所需尺寸的單一檔案——16 x 16 用於分頁標籤、32 x 32 用於書籤列、180 x 180 用於 iOS 主畫面捷徑。瀏覽器直接挑選正確的項目,無需解析影像標頭。

檔案標頭

ICO 標頭分兩部分:ICONDIRICONDIRENTRY 陣列。

ICONDIR(6 位元組)

Bytes 0-1: Reserved     (must be 0)
Bytes 2-3: Type         (1 = icon, 2 = cursor)
Bytes 4-5: Count        (number of images, little-endian uint16)

每個 ICO 檔案都以 00 00 01 00 開頭。接下來兩個位元組告訴你後面跟著幾張影像。

ICONDIRENTRY(每個項目 16 位元組)

每張影像對應一個項目:

Bytes 0:    Width         (in pixels; 0 means 256)
Bytes 1:    Height        (in pixels; 0 means 256)
Bytes 2:    ColorCount    (0 if more than 256 colors)
Bytes 3:    Reserved      (always 0)
Bytes 4-5:  Planes        (1 for icons)
Bytes 6-7:  BitCount      (bits per pixel: 1, 4, 8, 24, or 32)
Bytes 8-11: BytesInRes    (size of image data in bytes, uint32)
Bytes 12-15: ImageOffset  (byte offset to image data from file start, uint32)

Width 與 Height 欄位各佔一個位元組,能明確儲存的最大值是 255。當影像為 256 x 256 像素時,欄位存入 0x00,解碼器會將其解讀為 256。這個特性自 1985 年的規格以來一直存在。

以下是一個包含三張影像的 ICO 目錄範例——分別是 16 x 16 BMP、32 x 32 BMP 與 256 x 256 PNG:

Offset 0x00:  00 00 01 00 03 00                    -- ICONDIR: 3 images
Offset 0x06:  10 10 00 00 01 00 18 00 2C 01 00 00 16 00 00 00  -- 16x16, 24bpp, BMP, 300 bytes at 0x16
Offset 0x16:  20 20 00 00 01 00 18 00 A8 02 00 00 42 01 00 00  -- 32x32, 24bpp, BMP, 680 bytes at 0x142
Offset 0x26:  00 00 00 00 01 00 20 00 B4 1C 00 00 EA 03 00 00  -- 256x256, 32bpp, PNG, 7348 bytes at 0x3EA

作業系統讀取目錄、找到符合需求的項目,然後直接跳轉到 ImageOffset 指定的位置——無需額外解析,也不需推測,直接存取記憶體即可。

為什麼 PNG、WebP 和 JPEG 始終無法取代 ICO

以任何現代指標衡量,PNG 都優於 ICO 內部的點陣圖編碼。PNG 壓縮率更高、具備真正的 Alpha 透明通道、工具鏈普及。WebP 還能更小。JPEG 擅長處理照片。但 ICO 依舊存在,原因有三:

1. 單檔多解析度。 PNG 儲存單一影像。ICO 儲存任意數量。網站當然可以提供一組 PNG 的 ZIP 壓縮包,但沒有瀏覽器知道如何為 favicon 挑選正確尺寸。ICO 的目錄結構用六個位元組就解決了這個問題。

2. 系統 API 綁定。 Windows 的 LoadIconExtractIconSHGetFileInfo 全部預期 ICO 資料。Win32 API 沒有對應的 PNG 圖示容器機制。改變這一點會破壞自 1985 年以來編譯的每一個 Windows 應用程式。Microsoft 從不為了新功能而犧牲累積數十年的向下相容性。

3. Favicon 標準。 HTML 的 <link rel="icon"> 標籤接受 PNG、SVG 和 ICO,但隱含的預設請求 /favicon.ico 比這些選項更早出現。自 Internet Explorer 5(1999)以來的每一款瀏覽器都預設請求 /favicon.ico。沒有明確宣告 favicon 連結的網站仍然需要在這個路徑放一個 ICO,否則瀏覽器會收到 404。

PNG 沒有取代 ICO,因為 ICO 從未在影像品質上競爭。它勝出的是容器語義——沒有其他格式提供相同的「檔案中的目錄」模型,更遑論累積三十年的作業系統支援。

容器裡面裝什麼

ICO 檔案中的每個項目都指向一張獨立影像。影像資料可以是以下兩種格式之一:

BMP 編碼(舊版)

在 Windows Vista 之前,所有 ICO 影像都是未壓縮或 RLE 壓縮的 BMP 點陣圖。儲存的資料是 DIB(Device Independent Bitmap)——沒有 BITMAPFILEHEADER 的 BMP,直接以 BITMAPINFOHEADER 開頭:

Bytes 0-3:   Header size      (40 for BITMAPINFOHEADER)
Bytes 4-7:   Width            (uint32)
Bytes 8-11:  Height           (uint32, doubled for XOR + AND masks)
Bytes 12-13: Planes           (1)
Bytes 14-15: BitCount         (1, 4, 8, 24, or 32)
Bytes 16-19: Compression      (0 for uncompressed, 1 or 2 for RLE)
Bytes 20-23: Image size       (0 if uncompressed)
Bytes 24-27: X pixels per meter
Bytes 28-31: Y pixels per meter
Bytes 32-35: Colors used
Bytes 36-39: Important colors

DIB 標頭中的 Height 欄位是實際圖示高度的兩倍。32 x 32 的圖示在 Height 欄位儲存 64。上半部是 XOR 遮罩(實際彩色影像),下半部是 AND 遮罩(1-bit 透明點陣圖)。對於具備 Alpha 通道的 32-bit 圖示,AND 遮罩通常會被忽略。

對於 24-bit 及以下的 BMP 編碼圖示,若沒有 Alpha 通道,AND 遮罩就是唯一的透明機制。AND 遮罩中的 1 代表透明,0 代表不透明。這就是 Windows 在 32-bit 色彩成為標準之前實現透明的方式。

PNG 編碼(Windows Vista 起)

自 Windows Vista 開始,ICO 檔案可以儲存 PNG 編碼的影像。ICONDIRENTRY 中指定偏移量處的影像資料是原始 PNG 檔案——包含完整的 89 50 4E 47 簽名與完整 PNG chunk 結構。

這就是 256 x 256 圖示該用的格式。一張 256 x 256 32-bit BMP 未壓縮約 262 KB。同一張影像以 PNG 儲存通常只有 20–60 KB。現代圖示工具會為 256 x 256 產生 PNG 編碼項目,為較小尺寸產生 BMP 編碼項目,在相容性與檔案大小之間取得最佳平衡。

編碼比較

編碼方式首次出現壓縮方式Alpha 支援最適用尺寸
BMPWindows 1.0 (1985)無或 RLE1-bit AND 遮罩16 x 16 至 48 x 48
PNGWindows Vista (2006)DEFLATE8-bit alpha64 x 64 至 256 x 256

偵測與檢視 ICO 檔案

不要信任 .ico 副檔名。讀取前六個位元組並解析目錄。

TypeScript

interface IcoEntry {
  width: number
  height: number
  bitCount: number
  bytesInRes: number
  imageOffset: number
}

interface IcoInfo {
  valid: boolean
  type: "icon" | "cursor" | "unknown"
  count: number
  entries: IcoEntry[]
}

async function inspectIco(file: File): Promise<IcoInfo> {
  const buffer = await file.slice(0, 1024).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  if (bytes.length < 6)
    return { valid: false, type: "unknown", count: 0, entries: [] }

  const reserved = bytes[0] | (bytes[1] << 8)
  const type = bytes[2] | (bytes[3] << 8)
  const count = bytes[4] | (bytes[5] << 8)

  if (reserved !== 0 || (type !== 1 && type !== 2)) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  const entries: IcoEntry[] = []
  const dirSize = 6 + count * 16
  if (bytes.length < dirSize) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  for (let i = 0; i < count; i++) {
    const off = 6 + i * 16
    entries.push({
      width: bytes[off] === 0 ? 256 : bytes[off],
      height: bytes[off + 1] === 0 ? 256 : bytes[off + 1],
      bitCount: bytes[off + 6] | (bytes[off + 7] << 8),
      bytesInRes:
        bytes[off + 8] |
        (bytes[off + 9] << 8) |
        (bytes[off + 10] << 16) |
        (bytes[off + 11] << 24),
      imageOffset:
        bytes[off + 12] |
        (bytes[off + 13] << 8) |
        (bytes[off + 14] << 16) |
        (bytes[off + 15] << 24),
    })
  }

  return {
    valid: true,
    type: type === 1 ? "icon" : "cursor",
    count,
    entries,
  }
}

Python

import struct
from typing import TypedDict

class IcoEntry(TypedDict):
    width: int
    height: int
    bit_count: int
    bytes_in_res: int
    image_offset: int

class IcoInfo(TypedDict):
    valid: bool
    type: str
    count: int
    entries: list[IcoEntry]

def inspect_ico(path: str) -> IcoInfo:
    with open(path, "rb") as f:
        header = f.read(1024)

    if len(header) < 6:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    reserved, type_, count = struct.unpack("<HHH", header[:6])
    if reserved != 0 or type_ not in (1, 2):
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    dir_size = 6 + count * 16
    if len(header) < dir_size:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    entries: list[IcoEntry] = []
    for i in range(count):
        off = 6 + i * 16
        w, h, colors, _, planes, bit_count = struct.unpack("<BBBBHH", header[off:off+8])
        bytes_in_res, image_offset = struct.unpack("<II", header[off+8:off+16])
        entries.append({
            "width": 256 if w == 0 else w,
            "height": 256 if h == 0 else h,
            "bit_count": bit_count,
            "bytes_in_res": bytes_in_res,
            "image_offset": image_offset,
        })

    return {
        "valid": True,
        "type": "icon" if type_ == 1 else "cursor",
        "count": count,
        "entries": entries,
    }

Go

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

type IcoEntry struct {
	Width        int
	Height       int
	BitCount     int
	BytesInRes   uint32
	ImageOffset  uint32
}

type IcoInfo struct {
	Valid   bool
	Type    string
	Count   int
	Entries []IcoEntry
}

func inspectIco(path string) (IcoInfo, error) {
	f, err := os.Open(path)
	if err != nil {
		return IcoInfo{}, err
	}
	defer f.Close()

	buf := make([]byte, 1024)
	n, _ := f.Read(buf)
	buf = buf[:n]

	if n < 6 {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	reserved := binary.LittleEndian.Uint16(buf[0:2])
	typ := binary.LittleEndian.Uint16(buf[2:4])
	count := binary.LittleEndian.Uint16(buf[4:6])

	if reserved != 0 || (typ != 1 && typ != 2) {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	dirSize := 6 + int(count)*16
	if n < dirSize {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	entries := make([]IcoEntry, 0, count)
	for i := 0; i < int(count); i++ {
		off := 6 + i*16
		w := buf[off]
		h := buf[off+1]
		bitCount := binary.LittleEndian.Uint16(buf[off+6 : off+8])
		bytesInRes := binary.LittleEndian.Uint32(buf[off+8 : off+12])
		imageOffset := binary.LittleEndian.Uint32(buf[off+12 : off+16])

		if w == 0 { w = 256 }
		if h == 0 { h = 256 }

		entries = append(entries, IcoEntry{
			Width:       int(w),
			Height:      int(h),
			BitCount:    int(bitCount),
			BytesInRes:  bytesInRes,
			ImageOffset: imageOffset,
		})
	}

	typStr := "icon"
	if typ == 2 {
		typStr = "cursor"
	}
	return IcoInfo{Valid: true, Type: typStr, Count: int(count), Entries: entries}, nil
}

PHP

function inspectIco(string $path): array {
    $header = file_get_contents($path, false, null, 0, 1024);
    if (strlen($header) < 6) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $reserved = unpack("v", substr($header, 0, 2))[1];
    $type = unpack("v", substr($header, 2, 2))[1];
    $count = unpack("v", substr($header, 4, 2))[1];

    if ($reserved !== 0 || ($type !== 1 && $type !== 2)) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $dirSize = 6 + $count * 16;
    if (strlen($header) < $dirSize) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $entries = [];
    for ($i = 0; $i < $count; $i++) {
        $off = 6 + $i * 16;
        $w = ord($header[$off]);
        $h = ord($header[$off + 1]);
        $bitCount = unpack("v", substr($header, $off + 6, 2))[1];
        $bytesInRes = unpack("V", substr($header, $off + 8, 4))[1];
        $imageOffset = unpack("V", substr($header, $off + 12, 4))[1];

        $entries[] = [
            "width" => $w === 0 ? 256 : $w,
            "height" => $h === 0 ? 256 : $h,
            "bit_count" => $bitCount,
            "bytes_in_res" => $bytesInRes,
            "image_offset" => $imageOffset,
        ];
    }

    return [
        "valid" => true,
        "type" => $type === 1 ? "icon" : "cursor",
        "count" => $count,
        "entries" => $entries,
    ];
}

ImageMagick CLI

magick identify -verbose favicon.ico | grep -E "(Print size|Resolution|Colorspace|Type)"

列出所有內部影像:

magick identify favicon.ico

提取特定尺寸:

magick favicon.ico[2] extracted-256x256.png

或簡單使用:

file favicon.ico
# favicon.ico: MS Windows icon resource - 5 icons, 16x16, 32 bits/pixel, 32x32, 32 bits/pixel

最佳實務與使用場景

ICO 不是通用影像格式。它是針對特定脈絡的部署產物。在需要的情境中使用它,其他情境一律使用 PNG。

Favicons

現代網站應同時提供兩種格式:

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />

瀏覽器會挑選它支援的第一種格式。支援 SVG 的瀏覽器得到向量圖示,舊版瀏覽器與書籤工具則退回到 ICO。ICO 檔案應包含:

尺寸編碼方式用途
16 x 16BMP瀏覽器分頁、舊版 IE
32 x 32BMP工作列、書籤列
48 x 48BMPWindows 捷徑
180 x 180PNGiOS 主畫面圖示
256 x 256PNGWindows 檔案總管超大圖示檢視

包含這五個項目的 favicon ICO 通常為 30–50 KB。若不含 256 x 256 PNG,則可壓到 10 KB 以下。

Windows 應用程式圖示

Windows 應用程式的可執行檔會嵌入 ICO 資源。作業系統在執行期間從嵌入的目錄載入適當尺寸。針對 Windows 10 與 11 的桌面應用程式,應包含:

  • 16 x 16、24 x 24、32 x 32、48 x 48(BMP,用於舊版與標準 DPI)
  • 64 x 64、128 x 128、256 x 256(PNG,用於高 DPI 螢幕)

Windows 會在缺少確切尺寸時自動縮放,但縮放效果比直接渲染原生小尺寸更差。每個省略的尺寸都會犧牲視覺品質。

不該使用 ICO 的情境

  • 網頁內容圖片:使用 WebP、JPEG 或 PNG。ICO 沒有壓縮優勢,對行內影像也沒有瀏覽器原生渲染優勢。
  • 照片:ICO 不是為高色彩連續色調影像設計的。檔案大小會暴增。
  • 跨平台資源:macOS 使用 .icns,不是 ICO。Linux 使用 PNG 或 SVG。ICO 是原生 Windows 格式,只是剛好在網頁上也能運作。

ICO 的未來

ICO 的壽命會比大多數唱衰它的預言更長。原因與它誕生的理由相同:向下相容性。

SVG favicon 在技術上更優越。它們可以縮放到任何解析度、壓縮率勝過任何點陣格式,而且支援動畫與互動性。Chrome、Firefox 和 Safari 都支援 SVG favicon。但 ICO 依然存在,因為數百萬台裝置、企業系統與舊版瀏覽器仍然預設請求 /favicon.ico。沒有 ICO 檔案的網站會在伺服器日誌中產生 404,並在較舊的軟體中顯示破損圖示。

Windows 11 仍然使用 ICO 作為應用程式圖示、資料夾圖示與系統 UI。Win32 API 仍然預期 ICO 資料。Microsoft 對取代這個格式毫無興趣——沒有商業理由去破壞三十年的應用程式相容性。

真正發生變化的,是 ICO 檔案的生成方式。現代工具鏈——IconKitchen、Figma 外掛、線上產生器——會自動輸出包含 PNG 編碼高解析度項目的 ICO 檔案。容器本身維持不變,變的是裡面承載的內容。

沒有人會說自己熱愛 ICO 這個格式。但它到處都能運作、什麼都不會破壞、支援成本幾乎為零——這正是為什麼到了 2035 年,它依然會在那裡。

如果你需要從現有影像建立 ICO 檔案,JPG to ICO 可以直接在瀏覽器中將 JPG、PNG 和 WebP 來源轉換為多解析度 ICO 檔案——無需上傳、無需伺服器處理。它會產生標準尺寸矩陣,並自動為 256 x 256 選擇 PNG 編碼、為較小尺寸選擇 BMP 編碼,為你達到相容性與檔案大小的最佳平衡。

更多推薦閱讀