ブログ一覧

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-icoto-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 を書く

参考リンク

この記事をシェア