深掘り

ICOフォーマット解説:ファイル構造、ヘッダーとなぜ今も標準なのか

koboshiCo-founder
·8 分で読めます
ICOフォーマット解説:ファイル構造、ヘッダーとなぜ今も標準なのか
要約

ICOはコーデックではなくコンテナだ。1985年のWindows 1.0で登場し、1つのファイルに同じアイコンの複数解像度を格納し、OSが実行時に最適なサイズを選択できる。6バイトのヘッダーからSVGファビコンが淘汰できなかった理由まで、完全な技術解説をお届けする。

ICOファイルをHex Editorで開く。最初の6バイトはこうだ:

00 00 01 00 03 00

これがファイルヘッダーのすべてだ。00 00 は Reserved フィールド — 常に0。01 00 は Type フィールド — 1 はアイコンファイルを意味する(カーソルファイルは 2)。03 00 は Count フィールド — リトルエンディアンで 3、つまりこのICOに3つの独立した画像が含まれている。6バイトで、3つの独立した画像ファイルを格納するコンテナを記述する。残りのファイルは、それらの画像に関するメタデータと、画像データそのものだ。

ICOは圧縮アルゴリズムでも色空間でもなく、あくまでコンテナだ。すべてのWindowsマシンと主要ブラウザで今も使われている、最小かつ最も原始的なコンテナフォーマットに過ぎない。

ICOの起源

Microsoftは 1985年のWindows 1.0 でICOを導入した。1985年のPCエコシステムには、標準化された画像ファイルという概念が存在しなかった。JPEGも、PNGも、GIFもない。ビットマップデータは生のピクセル配列であり、各プログラムが独自のストレージ方式を持っていた。Microsoftは、プログラム、フォルダ、システムUI要素用のアイコンを配布する方法が必要だった。要件はシンプルだった:

  • OSが必要とするサイズの数に関わらず、アイコンあたり1ファイル
  • 実行時の高速なルックアップ — OSは画像をデコードしなくても寸法を知る必要がある
  • 256 KB RAMのシステムでの小さなメモリフットプリント

解決策はディレクトリ構造だった。ファイルは、内部に何枚の画像があるかを示すヘッダーで始まる。次にエントリの配列が続き、各エントリが1つの画像の幅、高さ、ビット深度、ファイル内のオフセットを記述する。OSはディレクトリを読み込み、現在の表示コンテキストに一致するエントリを選択し、そのオフセットにシークしてビットマップをレンダリングする。

この設計はZIPより2年早く、GIFより2年早く、JPEGより7年早い。ICOは、ファイルシステムが面白くなる前の、ミニチュアなファイルシステムだった。

ICOが存在する根本的理由

アイコンは単一の画像ではなく、複数サイズの画像セットだ。Windowsはファイルリストで16 x 16、デスクトップで32 x 32、Explorerの詳細ペインで48 x 48、「特大」ビューで256 x 256と、同じアイコンを異なるサイズで表示する。200%スケールの高DPIディスプレイでは64 x 64のソースから32 x 32アイコンをレンダリングする必要もある。OSは表示コンテキストに応じて最適なサイズを選び出すが、この選択は一瞬で済ませなければならない。

アイコンが個別のPNGファイルとして保存されていたら、OSは複数のファイルを開き、それぞれをデコードし、結果をキャッシュする必要があるだろう。ICOでは、OSは1つのファイルを開き、16バイトのディレクトリエントリを読み込み、ビットマップデータに直接ジャンプする。ディレクトリによりフォーマットは自己記述的になる。メタデータにデコーダーは不要だ。

ファビコンの場合も同じだ。ブラウザが /favicon.ico をリクエストすると、タブ用の16 x 16、ブックマークバー用の32 x 32、iOSホーム画面ショートカット用の180 x 180といった、必要なすべてのサイズを1つのファイルで受け取れる。ブラウザは画像ヘッダーを解析することなく、適切なエントリを選択できる。

ファイルヘッダー

ICOヘッダーは2つの部分から構成される: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 で始まる。次の2バイトが、続く画像の数を示す。

ICONDIRENTRY (各16バイト)

各画像に1つのエントリが割り当てられる:

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 フィールドはそれぞれ1バイトだ。明示的に格納可能な最大値は255だ。256 x 256ピクセルの画像では、フィールドに 0x00 が格納され、デコーダーはこれを256として解釈する。この特性は1985年以来、仕様の一部となっている。

以下は、3つの画像 — 16 x 16 BMP、32 x 32 BMP、256 x 256 PNG — を含むICOのディレクトリの実例だ:

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

OSはディレクトリを読み込み、必要に応じたエントリを見つけて ImageOffset にジャンプする。画像ヘッダーの解析も推測も必要なく、メモリに直接アクセスできる。

PNG、WebP、JPEGがICOを置き換えられなかった理由

あらゆる現代的な指標で、PNGはICOの内部ビットマップエンコーディングより優れたフォーマットだ。PNGは圧縮率が高く、真のアルファ透過を持ち、ツールのサポートが広範だ。WebPはさらに小さい。JPEGは写真を扱う。それでもICOは生き残っている。理由は3つだ:

1. 単一ファイルでのマルチ解像度。 PNGは1つの画像を格納する。ICOは任意の数の画像を格納する。WebサイトはPNGのZIPを配布できるが、どのブラウザもファビコン用に適切なものを選択する方法を知らない。ICOのディレクトリ構造は、これを6バイトで解決する。

2. システムAPIとの結合。 Windowsの LoadIconExtractIconSHGetFileInfo はすべてICOデータを期待する。Win32 APIには、PNGアイコンコンテナの同等物が存在しない。これを変更すると、1985年以来コンパイルされたすべてのWindowsアプリケーションが破損する。Microsoftが後方互換性を破棄するはずがない。

3. ファビコンの標準。 HTMLの <link rel="icon"> タグはPNG、SVG、ICOを受け入れるが、/favicon.ico の暗黙的なデフォルトリクエストはこれらの選択肢より前から存在する。Internet Explorer 5(1999年)以来のすべてのブラウザは、デフォルトで /favicon.ico をリクエストする。ファビコンリンクを明示的に宣言しないサイトは、そのパスにICOが必要だ。さもなければブラウザは404を受け取る。

PNGがICOを置き換えられなかったのは、ICOは画像品質で競争していなかったからだ。コンテナのセマンティクスで競争しており、30年のOSサポートを持つ同じファイル内ディレクトリモデルを提供する他のフォーマットは存在しない。

コンテナの内部構造

ICOファイルの各エントリは、独立した画像を指す。画像データは2つのフォーマットのいずれかになりうる:

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 フィールドは 実際のアイコン高さの2倍 だ。32 x 32のアイコンは、Height フィールドに 64 を格納する。上半分がXORマスク(実際のカラー画像)、下半分がANDマスク(1ビットの透過ビットマップ)だ。アルファチャンネルを持つ32ビットアイコンの場合、ANDマスクは通常無視される。

アルファを持たない24ビット以下のBMPエンコードアイコンの場合、ANDマスクが唯一の透過メカニズムだ。ANDマスクの 1 は透明を意味し、0 は不透明を意味する。これが、32ビットカラーが標準になる前のWindowsでの透過実現方式だ。

PNGエンコーディング(Windows Vista以降)

Windows Vista以降、ICOファイルはPNGエンコード画像を格納できる。ICONDIRENTRY で指定されたオフセットにある画像データは、独自の 89 50 4E 47 シグネチャと完全なPNGチャンク構造を持つ、生のPNGファイルだ。

これが256 x 256アイコンに必要なフォーマットだ。256 x 256 32ビットBMPは非圧縮で約262 KBになる。同じ画像をPNGにすると、通常20-60 KBだ。現代のアイコンツールは、256 x 256にはPNGエンコードエントリを、小さいサイズにはBMPエンコードエントリを生成し、互換性とファイルサイズの最適なバランスを実現する。

エンコーディング比較

エンコーディング導入時期圧縮方式アルファ対応最適な用途
BMPWindows 1.0 (1985)なしまたはRLE1ビットANDマスク16 x 16 ~ 48 x 48
PNGWindows Vista (2006)DEFLATE8ビットアルファ64 x 64 ~ 256 x 256

ICOファイルの検出と調査

.ico 拡張子を信頼するな。最初の6バイトを読み、ディレクトリを解析しろ。

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を使え。

ファビコン

現代のWebサイトでは、両方のフォーマットを提供する:

<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エクスプローラー特大ビュー

これら5つのエントリを持つファビコンICOは、通常30-50 KBだ。256 x 256 PNGを除けば、10 KB未満になる。

Windowsアプリケーションアイコン

Windowsアプリケーションの実行ファイルはICOリソースを埋め込む。OSは実行時に、埋め込まれたディレクトリから適切なサイズを読み込む。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を使用すべきでない場面

  • Webコンテンツ画像: WebP、JPEG、またはPNGを使用する。ICOには圧縮の優位性も、インライン画像のブラウザネイティブレンダリングのメリットもない。
  • 写真: ICOは高カラーの連続階調画像向けに設計されていない。ファイルサイズが爆発的に増大する。
  • クロスプラットフォームアセット: macOSは .icns を使用し、ICOは使用しない。LinuxはPNGまたはSVGを使用する。ICOはWindowsネイティブのフォーマットであり、Web上でも動作する。

ICOの未来

ICOは淘汰されるという大半の予言を裏切って、ずっと長く生き残るだろう。理由はその起源と同じだ:後方互換性。

SVGファビコンは技術的に優れている。あらゆる解像度にスケールし、どのラスターフォーマットより圧縮率が高く、アニメーションとインタラクティビティをサポートする。Chrome、Firefox、SafariはすべてSVGファビコンをサポートする。それでもICOは生き残る。数百万のデバイス、エンタープライズシステム、レガシーブラウザが、依然としてデフォルトで /favicon.ico をリクエストするからだ。ICOファイルのないサイトは、サーバーログに404を生成し、古いソフトウェアで破損したアイコンを表示する。

Windows 11でも、ICOはアプリケーションアイコン、フォルダアイコン、システムUIに使用される。Win32 APIは依然としてICOデータを期待する。Microsoftはこのフォーマットを置き換える意向を示していない — 30年のアプリケーション互換性を破壊するビジネス上の理由がない。

実際に変わっているのは、ICOファイルの作り方だけだ。現代のツールチェーン — IconKitchen、Figmaプラグイン、オンラインジェネレーター — は自動的にPNGエンコードされた高解像度エントリを含むICOファイルを出力する。コンテナそのものは変わらない。中身の品質が向上しているだけだ。

ICOが誰にとっても愛すべきフォーマットだと思うな。ただ、どこでも動作し、何も壊さず、サポートにコストがかからない。だからこそ、2035年になってもこのフォーマットは消えない。

既存の画像からICOファイルを作成する必要がある場合、JPG to ICO は、JPG、PNG、WebPソースをブラウザ内で直接マルチ解像度ICOファイルに変換する — アップロードなし、サーバー処理なし。標準的なサイズマトリックスを生成し、256 x 256には自動的にPNGエンコーディングを、小さいサイズにはBMPエンコーディングを選択し、互換性とファイルサイズの最適なバランスを実現する。

その他のおすすめ記事