Node.js の sharp は ICO を書き出せない — favicon.ico を約 30 行で手組みする(PNG-in-ICO)
複数サイトの favicon をスクリプトで一括生成する作業をしていて、壁に当たりました。Node.js の画像処理は sharp がデファクトスタンダードですが、sharp は ICO を書き出せません。そして代替の npm パッケージはメンテが止まりがちです。
結論から言うと、外部パッケージは要りませんでした。ICO はただのコンテナフォーマットで、PNG をヘッダ付きで並べるだけなら約 30 行で手組みできます。この記事はその種明かしとコードです。
sharp は ICO を書けない(読む方も半分ダメ)
sharp の出力フォーマットは JPEG / PNG / WebP / GIF / AVIF / TIFF などで、ICO は含まれていません。.toFormat('ico') はエラーになります。
さらに読み込み側にも制約があります。ICO には画像データを BMP で格納する古典的な形式と、PNG をそのまま格納する形式(後述)の 2 通りがあるのですが、BMP 格納の ICO は sharp で読み込むこともできません。手元の 16×16 の古典的 ICO を食わせると、こうなります。
Error: Input file contains unsupported image format既存の変換パッケージ(png-to-ico、to-ico など)を使う手もありますが、この用途のパッケージは小さく、メンテナンスが止まっているものが目立ちます。favicon 1 個のために favicons のような大型パッケージを入れるのも大げさです。
そこでフォーマット自体を見てみると、実は自分で書ける程度に単純でした。
ICO はただのコンテナ
ICO ファイルの構造は 3 つの部品でできています。
1. ICONDIR(ファイルヘッダ、6 バイト)
- オフセット 0: 予約領域(常に 0)— 2 バイト
- オフセット 2: 画像タイプ(ICO は 1)— 2 バイト
- オフセット 4: 収録画像数 — 2 バイト
2. ICONDIRENTRY(画像ごとの目次、16 バイト × N)
- オフセット 0: 幅(ピクセル、256 は 0 と書く)— 1 バイト
- オフセット 1: 高さ(同上)— 1 バイト
- オフセット 2: パレット色数(PNG なら 0)— 1 バイト
- オフセット 3: 予約領域(0)— 1 バイト
- オフセット 4: カラープレーン数(1 でよい)— 2 バイト
- オフセット 6: ビット深度(32 でよい)— 2 バイト
- オフセット 8: 画像データのバイト数 — 4 バイト
- オフセット 12: 画像データのファイル先頭からのオフセット — 4 バイト
3. 画像データ本体
ここに入れる画像データは BMP か PNG です。そして重要なのが、Windows Vista 以降は PNG をそのまま格納できること(いわゆる PNG-in-ICO)。BMP 形式は行を下から上に格納する、透過マスクを別領域に持つ、といった面倒な作法がありますが、PNG-in-ICO なら sharp が出力した PNG バッファを無加工でファイルに並べるだけです。現行の主要ブラウザはすべて PNG-in-ICO を表示できます。
つまり ICO の生成とは「6 バイト書く → 16 バイト × N 書く → PNG を並べる」だけの作業です。
実装(約 30 行)
元画像(PNG や SVG など sharp が読めるもの)から 16/32/48px のマルチサイズ ICO を作るコードです。
const sharp = require("sharp");
const fs = require("fs");
async function buildIco(src, sizes, outPath) {
// 各サイズの PNG バッファを sharp で用意する
const images = [];
for (const size of sizes) {
const buf = await sharp(src).resize(size, size).png().toBuffer();
images.push({ size, buf });
}
// ICONDIR(6 バイト)
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // 予約領域
header.writeUInt16LE(1, 2); // タイプ: ICO
header.writeUInt16LE(images.length, 4); // 画像数
// ICONDIRENTRY(16 バイト × N)
const entries = [];
let offset = 6 + 16 * images.length; // データ本体の開始位置
for (const { size, buf } of images) {
const entry = Buffer.alloc(16);
entry.writeUInt8(size === 256 ? 0 : size, 0); // 幅(256 は 0)
entry.writeUInt8(size === 256 ? 0 : size, 1); // 高さ
entry.writeUInt16LE(1, 4); // カラープレーン
entry.writeUInt16LE(32, 6); // ビット深度
entry.writeUInt32LE(buf.length, 8); // データ長
entry.writeUInt32LE(offset, 12); // データ位置
entries.push(entry);
offset += buf.length;
}
const ico = Buffer.concat([header, ...entries, ...images.map((i) => i.buf)]);
fs.writeFileSync(outPath, ico);
}
buildIco("logo.png", [16, 32, 48], "favicon.ico");ポイントは 2 つだけです。ICONDIRENTRY のオフセット計算は「ヘッダ 6 バイト + 目次 16×N バイト」から始めて、画像を並べるたびにデータ長を足していくこと。そして PNG バッファは一切加工せずそのまま連結することです。
細部の注意
- 収録サイズは 16/32/48 が実用的です。16/32 はブラウザのタブやブックマーク用、48 は Google 検索結果の favicon 推奨サイズ(48×48px 以上)用です。検索結果に favicon が出ない問題については「favicon を設定しているのに検索結果は地球儀のまま — 原因は robots.txt だった」も参照してください
- SVG を入力にすると綺麗です。sharp は SVG をラスタライズできるので、ロゴの SVG から各サイズを直接生成すれば縮小によるボケがありません。ただしテキスト要素を含む SVG は実行環境のフォントに依存するので、サーバーで生成する場合はフォントの有無を確認してください
- 検証はマジックバイトとブラウザで。生成したファイルの先頭 4 バイトが
00 00 01 00なら ICO のヘッダです(od -A x -t x1 favicon.ico | head -1)。あとは実際にブラウザで配信して、タブに表示されることを確認します - 256px を入れる場合は幅・高さフィールドに 0 を書くのを忘れずに。1 バイトのフィールドに 256 は入らないための仕様です
まとめ
- sharp は ICO を書き出せず、BMP 格納の古典的 ICO は読み込みもできない。Node.js で favicon.ico を生成したい場合、ここがエコシステムの穴になっている
- ICO は「6 バイトのヘッダ + 16 バイト × N の目次 + 画像データ」だけの単純なコンテナフォーマット
- Windows Vista 以降は PNG をそのまま格納できる(PNG-in-ICO)。現行ブラウザは全対応で、BMP の面倒な作法を回避できる
- sharp で各サイズの PNG バッファを作り、ヘッダと目次を Buffer で手書きして連結すれば、約 30 行・追加依存ゼロでマルチサイズ ICO が作れる
- 収録サイズは 16/32/48 が実用的(48 は Google 検索結果用)。256px を入れる場合はサイズフィールドに 0 を書く