ウェブアプリケーションを作っていると、表示する画像を動的に生成したいケースに遭遇します。
探してみると画像ファイルを生成するツールやライブラリは見つかるのですが、ウェブアプリケーションでは画像データを HTTP のレスポンスとして送信できればよいので、ファイルを介さずに扱えると便利です。
そんなことを考えつつ画像フォーマットを調べていたら、PNG の構造が思っていたよりもずっと単純ということに気がつきました。
そんなわけで、今回は PNG の生成を Elixir で書いてみることにします。
実装するにあたって、最初に最低限の PNG の情報を整理します。
ここでは設定値の細かな説明は省略しますので、詳しくは PNG の仕様や PNG - Wikipedia などを参照してみてください。
最小限の構成
PNG は、ファイルヘッダといくつかのチャンクと呼ばれる構造で構成されています。
チャンクはいくつか種類がありますが、ヘッダ情報を格納する IHDR チャンク、画像データを格納する IDAT チャンク、終端を表す IEND チャンクの 3 つが必須で、これらを一つずつ持つ構造が最小の構成になります(パレットカラーを利用するばあいは PLTE チャンクも必要ですが、今回は割愛)。

ファイルヘッダ
ファイルヘッダは、その内容が PNG であることをあらわす固定のデータです。
ファイルはこのデータを先頭に配置しなければなりません。

チャンク
チャンクの構造
ファイルヘッダ以外のデータは、チャンクという形式で格納されています。
チャンクは、データの長さ、チャンクタイプ、チャンクデータ、CRC からなるデータです。
長さ、タイプ、CRC はそれぞれ 4 バイトで、ビッグエンディアンで格納されます。
データは可変長で 0 のばあいもあります。
CRC はタイプとデータを連結した CRC-32 の値です。

IHDR チャンク
画像の大きさや使用する色の情報を格納する固定長のチャンクです。
width と height は画像の幅と高さをあらわす 4 バイトの数値でビッグエンディアンで格納されます。
bit depth は画素あたりのビットを表し、color type はフルカラーやグレースケール、アルファチャネルの有無などの情報を表します。
残りのデータで圧縮、フィルタリング、インタレースといった制御ができますが、この記事では省略します。

IDAT チャンク
画像イメージを格納するチャンクです。
構造は単純で、各ラインごとに適用するフィルタの種類をあらわす 1 バイトのデータを先頭に追加して、全体を Deflate で圧縮(いわゆる ZIP 圧縮)したものです。
一つのラインあたりのデータは、画像幅と画素あたりのバイト数(例えば 8 ビットフルカラーであれば、画素あたり 3 バイト)をかけた値になります。
画素あたりのビット数が 8 未満で、バイト列にしたときに端数が出るばあいは、パディングを追加してバイト区切りにそろえる必要があります。

IEND チャンク
終端をあらわすチャンクです。
チャンクデータを持たないため事実上固定データです。

実装に必要な知識
次に。
Elixir で実装するにあたり、必要な情報を整理しておきます。
画像データの圧縮
Elixir そのもにはデータ圧縮のライブラリは提供されていませんが、Erlang で zlib
が用意されているので、これを利用します。
www.erlang.org
CRC-32 の関数も Erlang に erlang:crc32/1
が用意されているので、これを利用します。
www.erlang.org
IO data
最後に。
今回は IO data という、普段はあまり意識しないデータ構造を利用するので、その説明を補足しておきます。
IO data は Elixir のデータ構造の一つですが、ドキュメントにあるように複雑なものではありません。
ドキュメントでは次のように記載されています。
hexdocs.pm
IO data is a data type that can be used as a more efficient alternative to binaries in certain situations.
(IO data は、特定の状況でバイナリのより効率的な代替手段として使用できるデータ型です。)
A term of type IO data is a binary or a list containing bytes (integers within the 0..255 range) or nested IO data. The type is recursive.
(IO data の項目の型は、バイナリまたはバイト(0..255範囲内の整数)またはネストされた IO data を含むリストです。型は再帰的です。)
たとえば、次のデータは IO.puts/1
で出力すると ABCD
と表示される IO data の例です。
[0x41, 0x42, 0x43, 0x44]
[0x41, [0x42, [0x43, [0x44]]]]
[[0x41, 0x42], [0x43, 0x44]]
[[0x41, 0x42], "CD"]
<<0x041, 0x42, 0x43, 0x44>>
[<<0x041, 0x42>>, <<0x43, 0x44>>]
このように柔軟な構造をしているので、「バイナリか、0 から 255 までの整数か、IO data そのものをリストで連結すればなんとかなる」便利なデータ構造になっています。
この柔軟さのおかげで、リストのネストやバイナリとの混在を、処理の途中でほとんど気にすることなく扱うことが可能になります。
先に紹介した zlib
の関数に入力するデータや出力されるデータも IO data の形式になっています。
これらを踏まえて。
PNG 画像の生成を Elixir で実装してみます。
実装
チャンクを構築する
まず、チャンクを構築する関数を書きます。
引数の type
がチャンクタイプ、data
がデータ本体です。
どちらも IO data 形式です。
チャンクの仕様にのっとり、長さ、タイプ、データ、CRC を連結したものを返します。
長さと CRC はビッグエンディアンの 32 ビット(4 バイト)のデータにしたいため、::big-32
を指定してバイナリデータに変換しています。
返却する値も、バイナリや IO data をリストにしたものなので、これも IO data 形式です。
defp chunk(type, data) do
length = IO.iodata_length(data)
crc = :erlang.crc32([type, data])
[<<length::big-32>>, type, data, <<crc::big-32>>]
end
圧縮する
Erlang の zlib
を利用してデータを圧縮します。
一連の手続きを一つの関数にまとめただけでライブラリの使い方そのままです。
ここで引数の data
の値も戻り値も IO data 形式です。
defp zip(data) do
z = :zlib.open()
:ok = :zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:ok = :zlib.deflateEnd(z)
:zlib.close(z)
compressed
end
画像を作成する
サイズが、幅width
、高さ height
の 8 ビットフルカラーの画像データ bitmap
から PNG のデータを作成する関数です。
bit_depth
や color_type
の値を変更すれば、他の画像フォーマットの PNG データを作成できます。
関数の処理を簡単にするために、ここでは bitmap
は、RGB の 3 バイトごとにリストかバイナリにしたものを要素としたリストで受け取ることを前提にしています。
具体的には [[r, g, b], [r, g, b], ...]
もしくは [<<r, g, b>>, <<r, g, b>>, ...]
という構造になっている必要があります。
data
の値を作成する部分で、bitmap
をラインごとに分割して、その先頭に 1 バイトの filter type を挿入しています。
その後 data
を圧縮したものが idat
の値になります
chunk/2
と zip/1
は先に説明した関数と同じものです。
ここでも戻り値は IO data 形式です。
defmodule Png do
@file_header <<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A>>
def generate(width, height, bitmap) do
bit_depth = 8
color_type = 2
compression_method = 0
filter_method = 0
interlace_method = 0
ihdr = <<
width::big-32,
height::big-32,
bit_depth,
color_type,
compression_method,
filter_method,
interlace_method
>>
data =
bitmap
|> Enum.chunk_every(width)
|> Enum.map(fn row ->
[filter_method, row]
end)
idat = zip(data)
[
@file_header,
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", "")
]
end
defp chunk(type, data) do
length = IO.iodata_length(data)
crc = :erlang.crc32([type, data])
[<<length::big-32>>, type, data, <<crc::big-32>>]
end
defp zip(data) do
z = :zlib.open()
:ok = :zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:ok = :zlib.deflateEnd(z)
:zlib.close(z)
compressed
end
end
実行
PNG ファイルを作成する
作成したモジュールを利用して実際に PNG ファイルを作成します。
ここでは 512x512 のグラデーションパタンのファイルを作成しています。
データは IO data 形式で生成されるため、File.write/2
を使ってそのままファイルに書き出すことが可能です。
width = 512
height = 512
bitmap =
for row <- 0..(height - 1), col <- 0..(width - 1) do
[
min(row, 255),
min(col, 255),
min(min(511 - row, 511 - col), 255)
]
end
png = Png.generate(width, height, bitmap)
File.write("grad.png", png)

Livebook で使う
Livebook で Kino をインストールすれば、生成した PNG データを Livebook 上で確認することができます。
hexdocs.pm
Livebook で新しいノートブックを開いて、Setup で Kino をインストールします。
Mix.install([:kino])
次に PNG ファイルを作成するために記述したコードを Livebook に貼り付けます。
ここで最後に File.write/2
でファイルに保存しているコードを IO.iodata_to_binary/1
に書き換えます。
IO.iodata_to_binary/1
は名前のとおり IO data をバイナリに変換するもので、バイナリ形式になった PNG データは Kino が自動的に画像として表示してくれます。
png = Png.generate(width, height, bitmap)
IO.iodata_to_binary(png)

宣伝
最後にちょっと宣伝です。
ここまで書いた内容をパッケージにまとめたものを hex.pm で公開しています。
グレースケールやパレットカラーにも対応しています。
よろしければどうぞ。
hex.pm
いつか読むはずっと読まない:ひとのあいだと書いて人間
ソフトウェアは、人そのものから作られるプロダクトなわけですが。
人それだけではなく、人と人との関係もまたソフトウェアの源泉の一つなのだなと思う今日この頃。
まさに、「ソフトウェアは人が人のためにつくるもの」、なのだと。