PNG を Node.js 組み込みだけで生成した
PWA の実装でアイコン画像が必要になった。manifest.webmanifest の仕様で 192px・512px の 2 サイズが要求される。サーバーには convert(ImageMagick)も inkscape も node-canvas も入っていなかったので、Node.js の zlib だけで PNG バイナリを組み立てることにした。
scripts/gen-icons.mjs として書き、node scripts/gen-icons.mjs で public/ 以下に PNG を生成する形にした。
PNG フォーマットの概要
PNG ファイルは次の構成になっている。
PNG signature (8 bytes) — 137 80 78 71 13 10 26 10
IHDR chunk — 画像サイズ・ビット深度・カラータイプ
IDAT chunk — 圧縮された画像データ(deflate)
IEND chunk — 終端
各 chunk は length(4B) + type(4B) + data + CRC32(4B) の形式。CRC32 は type + data の連結バイト列に対して計算する。
この記事の前提が成り立つ理由は、ここにある。IDAT chunk の中身は zlib ストリーム(RFC 1950)そのものだ。PNG 独自の圧縮形式ではない。つまり Node.js の zlib.deflateSync() が吐くバイト列を、そのまま IDAT chunk の data に入れられる。zlib だけで PNG が作れるのは、この一点に尽きる。残りは chunk の枠を手で組むだけの作業になる。
CRC32 の実装
外部ライブラリは使わず、256 要素の lookup table を自前で初期化した。
const crcTable = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
多項式 0xedb88320 は PNG 仕様が要求する IEEE 802.3 CRC32 のビット逆順表現。
ピクセルデータの組み立て
PNG のピクセルデータは「各行の先頭に filter byte を付けた生 RGB 列」を deflate 圧縮したもの。
filter byte が何かというと、PNG は各行に予測フィルタ(左隣・真上のピクセルとの差分など 5 種類)をかけてから圧縮することで圧縮率を上げられる。どのフィルタを使ったかを行ごとに 1 byte で記録する。今回はアイコンが単色背景+数本の線で、フィルタをかけても得が薄いので、全行 0(None=フィルタなし)にした。行頭に 1 byte 余計に積むのはこのためだ。
const rowSize = 1 + size * 3; // filter + RGB × width
const raw = Buffer.alloc(size * rowSize);
for (let y = 0; y < size; y++) {
raw[y * rowSize] = 0; // filter: None
for (let x = 0; x < size; x++) {
const off = y * rowSize + 1 + x * 3;
raw[off] = bg[0]; raw[off + 1] = bg[1]; raw[off + 2] = bg[2];
}
}
const compressed = deflateSync(raw, { level: 9 });
背景色は #15130e(サイトのダーク基調色)、シンボルの色は #ef9148。
Bresenham でシンボルを描く
>_ という 2 文字のシンボルを描くために Bresenham の線分アルゴリズムを実装した。stroke 幅は Math.max(2, Math.round(unit * 0.35))(unit = size / 8)で、小さいサイズでも潰れないようにしてある。
function drawLine(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
let err = dx - dy, x = x0, y = y0;
while (true) {
for (let tx = -stroke; tx <= stroke; tx++)
for (let ty = -stroke; ty <= stroke; ty++)
setPixel(x + tx, y + ty);
if (x === x1 && y === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x += sx; }
if (e2 < dx) { err += dx; y += sy; }
}
}
> は 3 点の折れ線で描いた。中央座標 (cx, cy)、単位 unit = size / 8 として:
chLeft = cx - unit×1.5 (左端)
chRight = cx + unit×0.5 (右端・頂点)
chTop = cy - unit×2 (上端)
chMid = cy (中央)
chBot = cy + unit×2 (下端)
drawLine(chLeft, chTop, chRight, chMid) // 上半分
drawLine(chRight, chMid, chLeft, chBot) // 下半分
_ は右寄りに横線を 1 本:
barLeft = cx + unit×0.2
barRight = cx + unit×2.2
barY = cy + unit×2 (> の下端と同じ高さ)
drawLine(barLeft, barY, barRight, barY)
検証
生成した PNG のヘッダを python3 で確認した。
python3 -c "
import struct
data = open('public/a2h-icon-192.png', 'rb').read()
print('PNG sig ok:', data[:8] == bytes([137,80,78,71,13,10,26,10]))
l = struct.unpack('>I', data[8:12])[0]
t = data[12:16]
print('IHDR chunk:', t, 'len:', l)
w, h, bd, ct = struct.unpack('>IIBB', data[16:26])
print(f' {w}x{h} bitdepth={bd} colortype={ct}')
"
出力:
PNG sig ok: True
IHDR chunk: b'IHDR' len: 13
192x192 bitdepth=8 colortype=2
512px 版も同様に確認した。
結果
a2h-icon-192.png が 658B、a2h-icon-512.png が 2485B で生成された。192px が小さいのは、単色の背景に折れ線数本だけで deflate がよく圧縮できるため。静的ファイルとしてリポジトリに commit した。
sharp や canvas のインストールを避けたかったという動機もあるが、PNG フォーマットが想像より素直で、zlib さえあれば難しくないことが分かった。