JavaScriptでJPEGを出力する際にEXIFやXMPのメタデータを付与する

#はじめに
Thetaの360°画像にぼかしを入れるWebアプリ[SphereBlur.com](https://sphereblur.com)を[作った](http://qiita.com/horo/items/abb8bf40c23b21ce6e5b)際に、元のJPEG画像から取り出した姿勢情報を、編集後に保存する際に一緒に書き込む必要があった。(JPEGから姿勢情報を取り出す方法については[こちら](http://qiita.com/horo/items/895a838bac2d9e762c88)参照)

#画像からJPEGデータへの変換方法
ブラウザに組み込まれたAPIとして、Canvasエレメントの

```js
var data = canvas.toDataURL('image/jpeg');
```

を呼び出す方法がある。しかし、この方法だと当然メタデータは保存できない。もちろん、このデータの中身にメタデータを追加するということは可能だが、いろいろ調べてみると、toDataURL()に頼らずに、JavaScriptだけでJPEGデータに変換する[JPEGEncoder](https://github.com/owencm/javascript-jpeg-encoder)というライブラリを見つけたので、そちらを使ってみることにした。

#JPEGEncoderの改造
JPEGEncoder自体にはメタデータを付与する機能はないので、少し改造する。JPEGEncoder.encode()メソッドを読んでいくと、

```js:jpeg_encoder_basic.js
        this.encode = function(image,quality,toRaw) // image data object
        {
            var time_start = new Date().getTime();

            if(quality) setQuality(quality);

            // Initialize bit writer
            byteout = new Array();
            bytenew=0;
            bytepos=7;

            // Add JPEG headers
            writeWord(0xFFD8); // SOI
            writeAPP0();
            writeDQT();
            writeSOF0(image.width,image.height);
            writeDHT();
            writeSOS();

            // 後略
        }
```

と、いかにもJPEGのヘッダーを書き込んでる部分があったので、下記のように、image.xmpがある場合はXMPのAPP1ヘッダーを書き込むように変更する。EXIFに関しても同様の改造をすれば対応できるが、今回はEXIFには対応してない。

```js:jpeg_encoder_basic.js
        this.encode = function(image,quality,toRaw) // image data object
        {
            var time_start = new Date().getTime();

            if(quality) setQuality(quality);

            // Initialize bit writer
            byteout = new Array();
            bytenew=0;
            bytepos=7;

            // Add JPEG headers
            writeWord(0xFFD8); // SOI
            writeAPP0();
            if (image.xmp) {
              var xap = "http://ns.adobe.com/xap/1.0/\0";
              writeWord(0xFFE1); // marker of APP1
              writeWord(image.xmp.length + xap.length + 3);
              for (var i=0; i<xap.length; i++) {
                  writeByte(xap.charCodeAt(i));
              }
              for (var i=0; i<image.xmp.length; i++) {
                  writeByte(image.xmp.charCodeAt(i));
              }
              writeByte(0);
            }
            writeDQT();
            writeSOF0(image.width,image.height);
            writeDHT();
            writeSOS();
            // 後略
        }
```

#WebGLのフレームバッファーからピクセルデータの取得
WebGLのフレームバッファーからピクセルデータの取得するには、gl.readPixels()を使う。

```js
  _getBufferPixelsForSave: function() {
    var pixels = new Uint8Array(this._frameBufferForEdit0.width * this._frameBufferForEdit0.height * 4);
    this._gl.bindFramebuffer(this._gl.FRAMEBUFFER, this._frameBufferForEdit0.f);
    this._gl.readPixels(0, 0, this._frameBufferForEdit0.width, this._frameBufferForEdit0.height, this._gl.RGBA, this._gl.UNSIGNED_BYTE, pixels);
    this._gl.bindFramebuffer(this._gl.FRAMEBUFFER, null);
    return pixels;
  },
```

#XMPメタデータの作成
[XMPの仕様](http://www.adobe.com/devnet/xmp.html)および、[Photo Sphere XMP Metadataの仕様](https://developers.google.com/streetview/spherical-metadata)に従って下記のようなXMLの文字列を作成する。


```js
  _createXMP: function() {
    var w = this._frameBufferForEdit0.width;
    var h = this._frameBufferForEdit0.height;
    var heading = 0;
    var pitch = 0;
    var roll = 0;
    if (this._poseInfo) {
      heading = this._poseInfo.heading;
      pitch = this._poseInfo.pitch;
      roll = this._poseInfo.roll;
    }
    var xmp =
      '<?xpacket begin="ï»¿" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
      '<x:xmpmeta xmlns:x="adobe:ns:meta/" xmptk="Sphere Blur">' +
        '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
          '<rdf:Description rdf:about="" xmlns:GPano="http://ns.google.com/photos/1.0/panorama/">' +
          '<GPano:ProjectionType>equirectangular</GPano:ProjectionType>' +
          '<GPano:UsePanoramaViewer>True</GPano:UsePanoramaViewer>' +
          '<GPano:CroppedAreaImageWidthPixels>' + w + '</GPano:CroppedAreaImageWidthPixels>' +
          '<GPano:CroppedAreaImageHeightPixels>' + h + '</GPano:CroppedAreaImageHeightPixels>' +
          '<GPano:FullPanoWidthPixels>' + w + '</GPano:FullPanoWidthPixels>' +
          '<GPano:FullPanoHeightPixels>' + h + '</GPano:FullPanoHeightPixels>' +
          '<GPano:CroppedAreaLeftPixels>0</GPano:CroppedAreaLeftPixels>' +
          '<GPano:CroppedAreaTopPixels>0</GPano:CroppedAreaTopPixels>' +
          '<GPano:PoseHeadingDegrees>' + heading + '</GPano:PoseHeadingDegrees>' +
          '<GPano:PosePitchDegrees>' + pitch + '</GPano:PosePitchDegrees>' +
          '<GPano:PoseRollDegrees>' + roll + '</GPano:PoseRollDegrees>' +
          '</rdf:Description>' +
        '</rdf:RDF>' +
      '</x:xmpmeta>' +
      '<?xpacket end="r"?>';
    return xmp;
  },
```

#JPEGファイルの保存
あとは、JPEGEncoderを使って実際にピクセルデータからXMP付きのJPEGデータのデータURLを作って、Aタグエレメントを作ってa.downloadを指定してa.click()を呼べばダウンロードすることができる。

```js
  save: function() {
    var pixels = this._getBufferPixelsForSave();
    var jpegEnc = new JPEGEncoder();
    var jpegUri = jpegEnc.encode({
      width: this._frameBufferForEdit0.width,
      height: this._frameBufferForEdit0.height,
      data: pixels,
      xmp: this._createXMP()
    }, 70);
    var a = document.createElement('a');
    a.href = jpegUri;
    document.body.appendChild(a);
    a.download = "image.jpg";
    a.click();
    document.body.removeChild(a);
  },
```