「favicon を作る」と言うと意外と工数がある: 16, 32, 48, 64, 180, 192, 512 と 7 サイズの PNG を用意して、HTML の
<link>もsite.webmanifestも書く。500 行 vanilla JS で「テキスト・絵文字・画像のどれかから 7 サイズ全部生成、HTML スニペットも自動出力」のツールを書いた。実装中の 技術的な肝は downscaling だった。drawImageで 512→16 を 1 ステップでやると小さい favicon が どろどろにボケる。段階的に halve していくと文字や図形のエッジが立つ。
🌐 Demo: https://sen.ltd/portfolio/favicon-generator/
📦 GitHub: https://github.com/sen-ltd/favicon-generator
「一発 downscale」の悲しい結果
最初に書いたバージョンはこう:
function renderAt(size, content) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
// 文字を target size 上で直接描く
ctx.font = `${size * 0.7}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(content, size / 2, size / 2);
return canvas;
}
これで 16×16 を作ると 小さく描きすぎたフォントのレンダリングがぼやけて読めない 状態になる。ブラウザのフォントレンダラは「11px の sans-serif」を綺麗に出すための hinting 持ってない (Canvas API はサブピクセル レンダリング非対応)。
別案: 512×512 でレンダリングしてから 1 ステップで drawImage(src, 0, 0, 16, 16)。これも どろどろ。理由は次の通り。
なぜ単発 downscaling がぼやけるか
drawImage の resampling は (デフォルトでは) bilinear interpolation。dst の 1 ピクセルが src の何ピクセルに対応するかを計算して、近傍 4 ピクセルから重み付き平均を取る。
512 → 16 の場合、dst 1 ピクセルが src 32×32 = 1024 ピクセルに対応する。bilinear は近傍 4 つしか見ない。残り 1020 ピクセルの情報は 完全に捨てる。源画像の文字エッジが間引かれて、消えるか潰れるかする。
これは Canvas 仕様の問題ではなく信号処理の問題で、4 倍以上の縮小には box filter (= 全ピクセル平均) を段階的に重ねる のが基本。
step downscaling: halve しながら縮める
正解は 2 倍ずつ halve:
512 → 256 → 128 → 64 → 32 → 16
各ステップで drawImage(prev, 0, 0, half, half) を呼ぶと、bilinear が src 2×2 = 4 ピクセルを 1 つに平均化 する。これは box filter と等価で、信号情報がきれいに保たれる。最後の 16×16 で文字のエッジが立つ。
実装:
export function downscaleSteps(srcSize, dstSize) {
if (srcSize <= 0 || dstSize <= 0) return [];
if (srcSize <= dstSize * 2) return [dstSize];
const steps = [];
let cur = srcSize;
while (cur > dstSize * 2) {
cur = Math.max(dstSize, Math.round(cur / 2));
steps.push(cur);
if (cur <= dstSize) break;
}
if (steps[steps.length - 1] !== dstSize) steps.push(dstSize);
return steps;
}
ロジック:
-
src ≤ 2x dstなら 1 ステップで OK (bilinear で十分な精度) - それ以外は 「次の dst × 2 を下回らない範囲」 で halve を重ねる
- 最後は dst にぴったり合わせる
例:
-
512 → 16:[256, 128, 64, 32, 16](5 step) -
192 → 16:[96, 48, 24, 16](4 step) -
64 → 32:[32](1 step、src ≤ 2x dstなので)
レンダリング側はこの step list を順に通す:
function downscale(srcCanvas, target) {
const steps = downscaleSteps(srcCanvas.width, target);
let current = srcCanvas;
for (const stepSize of steps) {
const next = document.createElement("canvas");
next.width = next.height = stepSize;
const ctx = next.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(current, 0, 0, stepSize, stepSize);
current = next;
}
return current;
}
imageSmoothingQuality: "high" で bilinear をフルにかける。Chromium 系では trilinear / bicubic に近い品質になる。
テストで step list を保証する
Node でテストできる pure 関数にしておいた:
test("each step is ≤ 2x the next", () => {
const steps = downscaleSteps(512, 16);
for (let i = 1; i < steps.length; i++) {
assert.ok(steps[i - 1] / steps[i] <= 2.5, `step ratio too aggressive: ${steps.join(",")}`);
}
});
test("final step is exactly dst", () => {
assert.equal(downscaleSteps(192, 16).pop(), 16);
});
test("when src ≤ 2x dst: single resize", () => {
assert.deepEqual(downscaleSteps(64, 32), [32]);
});
「ratio ≤ 2.5」は (2.0 厳密にすると 192 → 96 → 48 → 24 → 16 の 24/16=1.5 で OK だが 192/96=2.0 がぎりぎりなので) 数値誤差マージン。assert.ok(ratio <= 2.5) で実用十分。
マスター解像度を 512 に固定する
ユーザがテキストを入力したり絵文字を選んだとき、毎回 512×512 でマスター画像を描く。その後 step downscale を 7 種類の target size に適用する:
const HI_RES = 512;
function renderAtMaster(config) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = HI_RES;
// ... 背景、文字、絵文字、画像のいずれかを描画
return canvas;
}
export function renderAllSizes(config) {
const master = renderAtMaster(config);
const out = new Map();
for (const s of [16, 32, 48, 64, 180, 192, 512]) {
out.set(s, downscale(master, s));
}
return out;
}
メリット:
- テキストは 512px サイズで描画する (= フォントレンダラがフルに hinting を効かせられる)
- 各 target size には 必ず縮小 で到達 (拡大は発生しない)
- 形状マスク (rounded / circle) は 最終サイズで適用 (シャープなエッジ)
絵文字レンダリング
Canvas で絵文字を描くときは OS の絵文字フォントを font-family で指定 する:
ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.fillText("🚀", canvas.width/2, canvas.height/2);
macOS では Apple Color Emoji、Windows では Segoe UI Emoji、Linux/Android では Noto Color Emoji。color emoji はベクター → ラスター変換した PNG を埋め込んだフォントなので、Canvas で描いてもフルカラーで出る。
形状マスク
function applyShape(canvas, shape) {
if (!shape || shape === "square") return canvas;
const size = canvas.width;
const masked = document.createElement("canvas");
masked.width = masked.height = size;
const ctx = masked.getContext("2d");
ctx.save();
ctx.beginPath();
if (shape === "circle") {
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
} else if (shape === "rounded") {
const r = size * 0.22;
roundRect(ctx, 0, 0, size, size, r);
}
ctx.clip();
ctx.drawImage(canvas, 0, 0);
ctx.restore();
return masked;
}
clip() で path 内側だけ描画許可、drawImage で元 canvas を流し込む。downscale 後 に適用するのが大事 — 縮小と同時にマスクすると エッジが ぼやける。
PNG ダウンロード
Canvas → Blob → 一時 URL → 自動クリック の標準パターン:
export function downloadCanvasAsPng(canvas, filename) {
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}, 100);
}, "image/png");
}
ZIP で全サイズまとめ download も考えたが JSZip を bundle すると 100KB 級。個別ボタン にしたほうがシンプルで、必要なサイズだけ取れる利点もある。
アーキテクチャ
core.js ← SIZES, downscaleSteps, validateConfig, snippets (DOM 非依存、21 tests)
render.js ← Canvas で master 描画 → step downscale → 形状マスク
presets.js ← 6 種のプリセット
app.js ← UI グルー (input → validate → render → preview + download)
core.js には DOM が登場しない。Node テストで step list の正しさを完全保証してから、render.js が Canvas 操作を担当する。
まとめ
- favicon は 7 種類のサイズ + HTML + manifest が標準セット
- 単発の big-to-tiny downscale は bilinear filter の限界で必ずぼやける
- 正解は 2x ずつ halve を繰り返す step downscaling (4 ピクセル → 1 ピクセルの box filter に近づく)
- step list の計算は pure 関数として Node テストで保証できる (
(src) / 2 / 2 / ... → dst) - マスター画像は 512×512 固定、target には常に縮小、形状マスクは最終サイズで
- ZIP ダウンロードはあえてやらず、個別ボタン で必要なサイズだけ取れる
リポジトリ: https://github.com/sen-ltd/favicon-generator
このツールは弊社の OSS ポートフォリオ #255 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/