JavaScriptでPNGファイルをつくってみる(バイナリから)

以前のブログ記事でちょっとだけ触れた「JavaScriptPNGファイルをつくっているよ」な開発がなんとなくうまくいきはじめたので、ここまでのあれこれを記事にしてみます。 それにしても、こんなに長文のブログを書くのははじめてでつかれた。。。ぱたり。(もうつかれたので投稿してしまったけど、投稿すると査読能力が上がる説があるので、ちょいちょい書き直すかも...)

JavaScriptでバイナリを扱ってみる

以前から、これ系のネタをやってみたいな~と思っていました。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ファイルの構造を把握できると思います。ざっくりと説明すると、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ファイルシグネチャ

「これは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())        // カラータイプ(パレット使用・グレースケール・TrueColorなど)
      .concat(this._compressMethod())   // 圧縮形式(0のみが仕様で定義されるので0x00固定)
      .concat(this._filterMethod())     // フィルタ(0のみが仕様で定義されるので0x00固定)
      .concat(this._interlace())        // インタレース
  }

  /* チャンクデータの書き込み */
  write(bytes) {
    bytes.write(this._chunkLength());     // チャンクのバイト長
    bytes.write(this._chunkDataArray());  // チャンクデータ
    bytes.write(this._crc());             // CRC
  }

  /* 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 {
  // palette = [ {r: , g: , b: , a: }, ... ] の形式
  constructor(palette) {
    this.palette = palette;
  }

  /* Chunkの長さ */
  _chunkLength() {
    const length = this.palette.length * 3;
    return [length >>> 24, length >>> 16, length >>> 8, length].map( val => val & 0x000000FF);
  }

  /* Chunkのタイプ(=PLTE) */
  _chunkType() {
    return [0x50, 0x4C, 0x54, 0x45];  // PLTE
  }
  // ...(省略)...
}

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 {
  // ...(省略)...
  /* PLTE Chunk バイト列の生成 */
  write(bytes) {
    bytes.write(this._chunkLength());  // データのバイト長
    bytes.write(this._chunkData());    // パレットバイト列
    bytes.write(this._crc());          // 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;     // 2 ^ (slideWindowMode + 8) = actualSlideWindowSize  // スライド窓の大きさ
    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();
  }

  /* PNGシグネチャ定義 */
  _pngSignature() {
    return [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
  }

  /* PNGデータのバイト列を生成する */
  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();
    // bytes は PngBytes (png_bytes.js) 形式なので、UInt8Array型のバイト列(bytes.buffer)を与える
    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();             // バイト列からBlobを生成したものをもらう
  var result = new Image();
  result.src = URL.createObjectURL(blob);  // BlobのURLを取得してImgタグに適用
  document.body.appendChild(result);       // 結果をDOMに追加
}
image.src = '/examples/pngtest2.png';      // 画像のロード

作ったデータの検証

ブラウザにそれっぽく表示されていても、実際はデータが壊れている可能性があります。正しいデータかどうかを検証するために、libpng.org にて公開されているチェックツールをつかいました。

www.libpng.org

f:id:piyorinpa:20190908002745p:plain
PNGデータの検証結果

「No errors detected」と表示されたらよさそう。

あとがき

今回はPNGファイルを題材に、JavaScriptでバイナリからファイルを生成してみるという試みをしてみました。気づけば「JavaScriptの技術を使ってみる」という本来の主題よりも、ひたすらにPNGの仕様について調べる感じになっていた...。 これでファイルの仕様さえわかれば、wavとか、JPEGとか、いろいろ作れるようになるのでは?という気持ちがしてきて楽しくなってきました。

ではでは~。