以前のブログ記事でちょっとだけ触れた「JavaScript でPNG ファイルをつくっているよ」な開発がなんとなくうまくいきはじめたので、ここまでのあれこれを記事にしてみます。
それにしても、こんなに長文のブログを書くのははじめてでつかれた。。。ぱたり。(もうつかれたので投稿してしまったけど、投稿すると査読能力が上がる説があるので、ちょいちょい書き直すかも...)
以前から、これ系のネタをやってみたいな~と思っていました。JavaScript には TypedArray という、ビット長が固定されている配列を定義することができます。これを Blob(Binary Large Object) API と組み合わせることで、任意のバイナリ列からファイルをつくれます。TypedArrayの一種、 UInt8Array
をつかってPNG フォーマットに従ってバイナリ列を生成し、PNG ファイルをつくってみよう~という試行です。
なぜPNG ファイルをつくってみたの?
そもそもなぜこれをやろうかと思ったかというと...。つい数か月前までは、Twitter に画像をアップロードするとき、透過情報を含むPNG ファイルはJPEG に変換されずにアップロードできるというハックな仕様がありました。がらくたツールボックス の運営サービスのひとつ 「ぴこぴよ」では、この特性を利用し、透過情報とともにドット絵をTwitter にアップロードするシステムとしていました。(ドット絵はその特性上、JPEG 圧縮によりドットがぼやけることで作品の魅力を大きく損なうため、ドッターの間ではこの仕様はしばしば利用されていたのでした)
picopiyo.iconclub.jp
しかしながら、Twitter の仕様変更によって、「ある一定の大きさのPNG ファイルは圧縮をかけない」「一定以上の大きさのPNG ファイルは透過情報を含んだ場合もJPEG に変換する」「パレット付きPNG (PNG8形式)はそのまま投稿される」のように仕様が変更されたようです。
www.webtech.co.jp
ぴこぴよを使って投稿される画像は、「ある一定の大きさを超えない」場合が多く、JPEG 圧縮の対象となる画像が投稿される可能性はさほど高くないと思いつつ、PNG8で投稿できればいいな~と思っていました。そこで、クライアント側でCanvas 要素のデータをPNG8に変換する術があればな~、という気持ちになっていたのでした。
たぶん探せば似たようなライブラリがありそうですが、今回は「そもそもこれ系のネタに興味があった」ということで、おためしでTrueColorデータなPNG からPNG8に変換する君をつくってみよう、と思ったのでした。
この記事について
わたしはまだPNG ファイルの仕様に詳しいわけではないので、当該記事の文章や、掲載したソースコード の正確性や完全性は保証できないことに注意してください。間違った記述やご指摘はコメントなどへぜひ~。
PNG のファイルの構成
PNG データを作り上げるためには、PNG の構造について知る必要があります(というか、このブログ記事はほぼPNG フォーマットに対応したソースコード の説明になっているような...。)。インターネットにたくさんの資料が公開されています。ありがたや~。今回参考にさせていただいたサイトをぺたり。
PNG ファイルの大まかな構造について(各Chunkについて)
PNG データの仕様
deflate(PNG ファイル内の圧縮データの仕様)
zlib (PNG ファイル内の圧縮データの仕様)
IDAT Chunkを読む
上記のウェブサイトを読むことで、PNG ファイルの構造を把握できると思います。ざっくりと説明すると、PNG ファイルは「これはPNG ですよ~」というシグネチャ と、データの塊である「Chunk」によって構成されています。Chunkにはいろいろありますが、画像の概要を表す「IDHR Chunk」や、パレットの情報を扱う「PLTE Chunk」、画像データを扱う「IDAT Chunk」、データの終端を表す「IEND Chunk」 などがあります。必須ChunkやオプショナルなChunkを組み合わせて画像ファイルを構成しています。
ということで、作っている途中のソースコード 全体はこちら~
github.com
今回つくったPNG データの構成
今回つくったPNG データは「PNG ファイルシグネチャ 」と、「IDHR」「PLTE」「tRNS」「IDAT」「IEND」Chunkを順番に並べて構成しました。
「これはPNG データですよ!」と宣言するためのファイルの先頭データです。仕様通りのバイト列を並べればよいだけなので、こんなかんじのデータを用意します。
const PngSignature = [ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] ;
IDHR Chunk
画像の概要情報を格納するChunkです。幅や高さ、ビット深度、カラーモード(パレットモード / True Color / グレースケール)などを設定します。
ソースコードはこちら
IDHR Chunkのバイト列を生成する IdhrChunk
クラスを定義しておきます。
export default class IdhrChunk {
constructor(width, height, depth, colorType) {
this .width = width;
this .height = height;
this .depth = depth;
this .colorType = colorType;
}
}
Chunk全体のバイト数を4バイトで表現します。(IDHR Chunkのバイト数は変化しないので、決め打ちで13bytesを入れておきます)
export default class IdhrChunk {
_chunkLength() {
return [ 0x00, 0x00, 0x00, 0x0D] ;
}
}
つづいて幅と高さを4バイトで表現します。幅や高さを4バイトに分割し、要素数 4の配列に直します。
export default class IdhrChunk {
_imageWidth() {
return [this .width >>> 24, this .width >>> 16, this .width >>> 8, this .width] .map( val => val & 0x000000FF);
}
_imageHeight() {
return [this .height >>> 24, this .height >>> 16, this .height >>> 8, this .height] .map( val => val & 0x000000FF);
}
}
同様に、仕様に則り、ビット深度やインターレース の有無などを記述していきます。(くわしくは idat_chunk.js を参照してください)
最終的にはだいたいこんなかんじ。
※ bytes.write()
というメソッドが突然出てきましたが、これはバイト列を連続して書き込むことを楽に実現するための PngBytes
クラスのメンバです。 -> png_bytes.js
export default class IdhrChunk {
_chunkDataArray() {
return this ._chunkType()
.concat(this ._imageWidth())
.concat(this ._imageHeight())
.concat(this ._depth())
.concat(this ._colorType())
.concat(this ._compressMethod())
.concat(this ._filterMethod())
.concat(this ._interlace())
}
write(bytes) {
bytes.write(this ._chunkLength());
bytes.write(this ._chunkDataArray());
bytes.write(this ._crc());
}
_crc() {
const crc = CrcCalculator.calc(this ._chunkDataArray());
return [ crc >>> 24, crc >>> 16, crc >>> 8, crc] .map( val => val & 0x000000FF);
}
}
各Chunkの整合性を確認するために、上記プログラムの CrcCalculator
で4バイトの「CRC32」を計算し、Chunkの末尾に付与します。計算ロジックはPNG 仕様の以下のサンプルプログラムを参考にしました。
www.libpng.org
上記サンプルプログラムをJavaScript に書き直した、CRC32の計算を行うプログラムは以下の通り。
https://github.com/piyoppi/png-palette.js/blob/ba16f34b62f48dc80c6daef72224f8e583cec1cb/src/crc_calculator.js
PLTE Chunk
パレット情報を格納するChunkです。
TrueColorデータのPNG の場合は、パレットが不要(画素の値がRGB値のため)なのでこのChunkは不要です。パレットを使う場合は画素の値がパレット番号になるというイメージです。
たとえば、3x1pxの赤、緑、青のPNG の画素を表現するとき、TrueColorデータの場合は 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF
(RGB値を並べたバイト列)となりますが、パレットを使う場合は色とインデックスを「赤=0, 青=1, 緑=2」のように割り当て、0, 1, 2
のように表現されます。
パレット情報オブジェクトを受け取り、PLTE Chunkのバイト列を生成するクラス PlteChunk
を定義します。ソースコードはこちら
チャンクの長さはパレットの色数によって変わるので、 _chunkLength()
関数のようにパレット数に応じて長さを4バイトのバイト列に変換します。
_chunkType()
関数にて、 PLTE
文字のAsciiコードを返します。
export default class PlteChunk {
constructor(palette) {
this .palette = palette;
}
_chunkLength() {
const length = this .palette.length * 3;
return [ length >>> 24, length >>> 16, length >>> 8, length] .map( val => val & 0x000000FF);
}
_chunkType() {
return [ 0x50, 0x4C, 0x54, 0x45] ;
}
}
ChunkのデータはRGBデータを順番に並べてつくっていきます。最初のRGB値の組がインデックス0、次のRGB値の組がインデックス1、となります。
export default class PlteChunk {
_palette() {
let palette = [] ;
this .palette.forEach( color => {
palette.push(color.r);
palette.push(color.g);
palette.push(color.b);
} );
return palette;
}
}
あとは、こんなかんじに並べていけばChunkのバイト列が完成します。
export default class PlteChunk {
write(bytes) {
bytes.write(this ._chunkLength());
bytes.write(this ._chunkData());
bytes.write(this ._crc());
}
}
tRNS Chunk
透明度情報を格納する TrnsChunk
クラスを定義します。
ソースコードはこちら
PLTE Chunk と同じようにパレットに対応したアルファ値(透明度情報)を順番に並べていきます。
export default class TrnsChunk {
constructor(palette) {
this .palette = palette;
}
_chunkType() {
return [ 0x74, 0x52, 0x4E, 0x53] ;
}
_alphaPalette() {
return this .palette.map( color => color.a );
}
}
IDAT Chunk
画像データを格納する IdatChunk
クラスを定義します。パレットの場合はパレットのインデックス、TrueColorデータの場合はRGB値が格納されます。
ソースコードはこちら
前述にもある通り Chunk の長さは4バイトで表現するため、4バイトで表現できる整数の最大値を超える長さのデータがあるときに、一つのIDAT Chunk では収まらないことになります。なので、 IDAT Chunk は複数存在してもよいことになっているようです。
コンストラク タは、(圧縮前の)生PNG バイト配列 data
を受け取ります。生PNG データの例は以下のサイトの内容が分かりやすいです。(以下のサイトではTrueColor形式のデータが掲載されていますが、RGB値の部分がパレットデータの場合は1画素ごとに1バイトで表現されるインデックスになります)
blog.livedoor.jp
export default class IdatChunk {
constructor(data, option = {} ) {
this .data = data;
this .fdict = option.fdict || 0;
this .flevel = option.flevel || 2;
this .slideWindowMode = option.slideWindowMode || 7;
this .dataMode = option.dataMode || DeflateDataType.raw;
this ._calculatedCompressedValue = null ;
if ( this .data && (this .data.length > 0) && this .dataMode === DeflateDataType.fixedHuffman ) {
this .compress();
}
}
}
PNG データはzlib形式で圧縮されます。アルゴリズム については以下の資料が詳しいです。
- RFC 1950 ZLIB Compressed Data Format Specification version 3.3 日本語訳
- RFC 1951 DEFLATE Compressed Data Format Specification version 1.3 日本語訳
とてもざっくり説明すると、あるバイト列が渡されたときに、「バイトを別の短い符号に置き換える」ことと「同じバイト列がデータ内に含まれている場合は、Nバイト前に長さLぶん同じデータがありますよ、という情報に置き換える」ことで圧縮をしようという可逆圧縮 アルゴリズム です。
たとえば、バイト列 0x00 0x00 0xFF 0x30 0x00 0x00 0xFF
みたいなデータが存在するときには [A B C] [D] [4つ前の長さ3のデータと同じ]
みたいな感じに置き換えるイメージです。
「プリセット辞書」(圧縮率を高めるために設定する事前データ)には今回は対応していないので FDICT
には0を代入しています。また、圧縮効率を示す FLEVEL
には固定値 2
(デフォルトアルゴリズム を使って圧縮した)を代入しています(このへんの値は不適当かも。。。ただし、解凍に直接かかわる値ではないので、これでも正しいPNG ファイルとして認識されます。)
圧縮アルゴリズム に登場する「スライド窓」の大きさ slideWindowMode
や、圧縮方式 dataMode
(全く圧縮しない or 固定ハフマン符号による圧縮)も設定します。
画像データの中身
画像データは、こんなかんじのバイト列によって構成されます。CMF
はスライド窓の大きさを格納し、 FLG
はプリセット辞書や圧縮レベルを格納します。 ImageData
には圧縮された画像データを、 Adler32
には圧縮前バイト列をもとに生成されるAdler32形式のチェック用のバイト列(4バイト)をセットします。
+-----+-----+=============+---+---+---+---+
| CMF | FLG | Image Data | Adler32 |
+-----+-----+=============+---+---+---+---+
CMF
FLG
バイトは、こんなかんじのコードで生成しています。
export default class IdatChunk {
_cmf() {
return [this .slideWindowMode << 4 | 0x08] ;
}
_flg() {
const cmf = this ._cmf();
const fval = this .flevel << 6 | this .fdict << 5;
const fcheck = 31 - (((cmf * 256) + fval) % 31);
return [ fval | fcheck] ;
}
}
とりあえず圧縮せずにファイル生成してみる
前述の ImageData
には「圧縮したデータが入ります」といいつつ、実は圧縮しないデータを入れることもできます。(仕様はこちら)https://www.futomi.com/lecture/japanese/rfc1951.html#s3_2_4
圧縮しない場合は以下のように、bfinal
ビット(最後のブロックデータの場合は1を立てる)と生データの長さ2byte(とその補数2byte)をセットし、あとはひたすらPNG の生データを詰めていきます。
※ PngBytes
というクラスが突然出てきましたが、これはバイト列を連続して書き込むことを楽に実現するためのラッパクラスです。 -> png_bytes.js
export default class IdatChunk {
_raw() {
const cycle = Math.ceil(this .data.length / 32768);
const bytes = new PngBytes(this .rawDataLength);
let writeBitCount = 0;
let dataCursor = 0;
for ( let i=0; i<cycle; i++ ) {
const bfinal = (cycle-1) === i ? 1 : 0;
const dataLength = Math.min(this .data.length, 32768);
const dataLengthComplement = (~dataLength) & 0xFFFF;
bytes.write([ 0x00 | bfinal] );
bytes.write([ dataLength, dataLength >>> 8] .map( val => val & 0xFF ));
bytes.write([ dataLengthComplement, dataLengthComplement >>> 8] .map( val => val & 0xFF ));
for ( let n=0; n<this .data.length; n++ ) {
bytes.write([this .data[ dataCursor++]] );
}
}
bytes.write(this ._adler32());
return bytes;
}
}
Adler32を求めるアルゴリズム は、RFC1950 に掲載されているサンプルコードをJavaScript に書き直したものをつかいました -> adler32.js
固定ハフマン符号を使った圧縮
無圧縮でもPNG データを表現できますが、せっかくなのでdeflate圧縮をおためし実装してみました。前述の ImageData
にdeflate圧縮したデータを詰めていきます。(愚直に実装しただけなので、パフォーマンスや圧縮効率についてはまったく担保できていません...) -> deflate.js
固定ハフマン符号による圧縮は、ざっくりいうと圧縮アルゴリズム のうち「あるバイトを別の短い符号に置き換える」でいう「符号」を、予め RFC1951で定義された固定ハフマン符号 としますよ~という感じです。
たとえば、以下のようなデータが存在したとします。
0xAA, 0x00, 0xFF, 0x00, 0xF0, 0x0F, 0x00, 0xFF, 0x00, 0xB0, 0xB0, 0x0F, 0xF0, 0x0F, 0x00, 0xFF, 0x04
このバイト列を圧縮する場合、以下のようなデータになり得ます(わかりやすいように二進数で記述してみます)。※ 一部のデータが圧縮によって「長さと距離」に置き換えられていることに注意してください。
データの中身
ビット列
bfinal と btype
011
符号化した0xAA
010101011
符号化した0x00
00001100
符号化した0xFF
111111111
符号化した0x00
00001100
符号化した0xF0
000011111
符号化した0x0F
11111100
符号化した長さ3
1000000
符号化した距離5
00100
距離5の拡張ビット
0
符号化した0xB0
000011011
符号化した0xB0
000011011
符号化した0x0F
11111100
符号化した長さ4
0100000
符号化した距離8
10100
距離8の拡張ビット
1
符号化した0x04
00101100
符号化した256(ブロックの終端を表す)
0000000
上の表を上から順に、左からすき間なく並べていくことで圧縮データが表現されます。(すき間なく並べるので、各ビット列はバイトをまたぐ場合もあります)
注意しなければならないのが、「符号化した」データはビットを右から詰めていく必要があり、それ以外のデータはビットを左から詰める必要があります。たとえば、「符号化した0xAA」は、 RFC1951で定義された固定ハフマン符号 をみると、ビット列 110101010
で表現されることが分かりますが、ビットを右から詰める必要があるので、ビット列として並べるときは上記の表のように 010101011
となります。
※筆者はこのことに気づかず、すごくはまってしまいました...。以下のサイトにて解説があり、ようやっと正しいデータが作れたのでした。
darkcrowcorvus.hatenablog.jp
くわしい実装が気になる方は deflate.js をみてみてください。
IEND Chunk
終端情報を格納する IendChank
クラスを定義します。
ソースコードはこちら
ソースコード をご覧になるとわかるように、この Chunk のバイト列は固定になります。
Chunkを組み合わせてPNG データを構成する
これらのChunkを組み合わせて、PNG データを吐き出してみます。今回はTrueTypeなPNG データをパレットに変換するクラス PngConv
を用意してみます。
コンストラク タで Image
を受け取り、 _getPixelData()
でCanvas をつかってピクセル データ(RGBA値)配列を取得します。(せっかくここまでPNG データの取り扱いを頑張ってきたけど、ここでCanvas に頼ってしまったのは、単に面倒だったためなのでした...)
convert()
によってパレットデータを作成したあと(この辺の処理はpng.js に定義していますが、本題からそれるので割愛)、fileData()
でChunkを組み合わせてPNG データを作り上げます。
export default class PngConv {
constructor(img) {
this .img = img || null ;
this .png = null ;
}
_getPixelData() {
const canvas = document .createElement('canvas' );
const ctx = canvas.getContext('2d' );
canvas.width = this .img.width;
canvas.height = this .img.height;
ctx.drawImage(this .img, 0, 0);
return ctx.getImageData( 0, 0, this .img.width, this .img.height ).data;
}
_prepare() {
const data = this ._getPixelData();
this .png = new Png(data, this .img.width, this .img.height);
}
convert() {
if ( !this .png ) this ._prepare();
this .png.convertToPaletteMode();
}
_pngSignature() {
return [ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] ;
}
fileData() {
const idhrChunk = new IdhrChunk(this .png.width, this .png.height, 8, ColorType.palette | ColorType.color);
const plteChunk = new PlteChunk(this .png.palette);
const trnsChunk = new TrnsChunk(this .png.palette);
const idatChunk = new IdatChunk(this .png.rawData, { dataMode: DeflateDataType.raw} );
const iendChunk = new IendChunk();
const byteLength = 8 + idhrChunk.length + plteChunk.length + trnsChunk.length + idatChunk.length + iendChunk.length;
const bytes = new PngBytes(byteLength);
bytes.write(this ._pngSignature());
idhrChunk.write(bytes);
plteChunk.write(bytes);
trnsChunk.write(bytes);
idatChunk.write(bytes);
iendChunk.write(bytes);
return bytes;
}
}
これでPNG データはできた!わーい!ということで、こんな感じのソースコード を書くと、生成したPNG データのBlobオブジェクトをつくることができます。(ここでようやっと本来やりたかったBlobが出てきた...)
export default class PngConv {
toBlob() {
const bytes = this .fileData();
return new Blob([ bytes.buffer] , { type: 'image/png' } );
}
}
あとは、つくった PngConv
クラスを呼び出して、こんなかんじにすると生成したPNG ファイルを表示することができます。
var image = new Image();
image.onload = function () {
var pngConv = new PngConv(image);
pngConv.convert();
var blob = pngConv.toBlob();
var result = new Image();
result.src = URL.createObjectURL(blob);
document .body.appendChild(result);
}
image.src = '/examples/pngtest2.png' ;
作ったデータの検証
ブラウザにそれっぽく表示されていても、実際はデータが壊れている可能性があります。正しいデータかどうかを検証するために、libpng.org にて公開されているチェックツールをつかいました。
www.libpng.org
PNG データの検証結果
「No errors detected」と表示されたらよさそう。
あとがき
今回はPNG ファイルを題材に、JavaScript でバイナリからファイルを生成してみるという試みをしてみました。気づけば「JavaScript の技術を使ってみる」という本来の主題よりも、ひたすらにPNG の仕様について調べる感じになっていた...。
これでファイルの仕様さえわかれば、wavとか、JPEG とか、いろいろ作れるようになるのでは?という気持ちがしてきて楽しくなってきました。
ではでは~。