File APIで画像処理を行う際の注意点のまとめ

File APIを用いると、画像データを読み込んでcanvas上に表示したり、それを加工して出力したりできる。
しかし、実装するには読み込む画像のフォーマットや環境ごとの差など、いくつか考慮しなければいけない点があったので、ここにまとめておく。

# ファイルフォーマットの正確な判定

File APIで読み込んだファイルのフォーマットはFile.typeプロパティで確認できる。
しかし、このtypeプロパティの値は拡張子に応じたMIMEタイプを返しているだけなので、仮に拡張子を偽装したファイルの場合、本当のファイルフォーマットとは異なるため、続く処理でエラーが出てしまう場合がある。
そのため、本当のファイルフォーマットを判定するには**マジックナンバー**を用いる。

> フォーマット識別子としてのマジックナンバーとは、ファイルの種類を識別するのに使われるファイル本文中の（内容中の）特定の位置にある特定の数値のことである。ファイルの種類を識別する方法としてはファイルの拡張子や属性値（プロパティ）を使う場合もあるが、マジックナンバーとはそれらのことではなく、ファイルの本文中に表れる特定の数値のことである。ほとんどの場合、マジックナンバーはファイルの先頭に位置し、数バイト程度である。
> 
> [マジックナンバー (フォーマット識別子)](https://ja.wikipedia.org/wiki/%E3%83%9E%E3%82%B8%E3%83%83%E3%82%AF%E3%83%8A%E3%83%B3%E3%83%90%E3%83%BC_(%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88%E8%AD%98%E5%88%A5%E5%AD%90))より引用

今回はFile APIで画像ファイルを扱うため、一般的なWeb画像形式を判定できるようにする。

| フォーマット | バイナリ |
|---|---|
| png | 89 50 4E 47 |
| gif | 47 49 46 38 |
| bmp | 42 4D |
| jpg, jpeg | FF D8 FF |

参考：[List of file signatures](https://en.wikipedia.org/wiki/List_of_file_signatures)

```js
function getImageFormat(arrayBuffer) {
  var arr = new Uint8Array(arrayBuffer).subarray(0, 4);
  var header = '';
  
  for(var i = 0; i < arr.length; i++) {
    header += arr[i].toString(16);
  }
  
  switch(true) {
    case /^89504e47/.test(header):
      return 'image/png';
    case /^47494638/.test(header):
      return 'image/gif';
    case /^424d/.test(header):
      return 'image/bmp';
    case /^ffd8ff/.test(header):
      return 'image/jpeg';
    default:
      return 'unknown image type';
  }
}
```

<p data-height="265" data-theme-id="0" data-slug-hash="QrQeMe" data-default-tab="result" data-user="hal9188" data-embed-version="2" data-pen-title="get image format by magic number" class="codepen">See the Pen <a href="https://codepen.io/hal9188/pen/QrQeMe/">get image format by magic number</a> by Haruumi Kondo (<a href="https://codepen.io/hal9188">@hal9188</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# jpegの向き

スマートフォンやデジカメで撮影した写真をFile APIで読み込んでcanvasに表示すると、撮影した機器で見る画像の向きとcanvasに描画された画像の向きが異なる場合がある。
これはjpegのEXIF情報が持つ撮影方向(Orientation)が反映されていないため。

| Orientation値 | 補正前の画像の向き |
|--:|---|
| 1 | そのまま |
| 2 | 左右反転 |
| 3 | 180度回転 |
| 4 | 上下反転 |
| 5 | 上下反転かつ時計回りに90度回転 |
| 6 | 時計回りに90度回転 |
| 7 | 上下反転かつ時計回りに270度回転 |
| 8 | 時計回りに270度回転 |

そのため画像を正しい向きに表示するには、jpegからOrientation値を取得し、それに応じてcanvasに描画する際に向きを補正してやる必要がある。

**向きの取得**

```js
function getOrientation(buffer) {
  var view = new DataView(buffer);
  if (view.getUint16(0, false) !== 0xFFD8) return -2;
  var length = view.byteLength, offset = 2;
  while (offset < length) {
    var marker = view.getUint16(offset, false);
    offset += 2;
    if (marker === 0xFFE1) {
      if (view.getUint32(offset += 2, false) !== 0x45786966) return -1;
      var little = view.getUint16(offset += 6, false) === 0x4949;
      offset += view.getUint32(offset + 4, little);
      var tags = view.getUint16(offset, little);
      offset += 2;
      for (var i = 0; i < tags; i++) {
        if (view.getUint16(offset + (i * 12), little) === 0x0112) return view.getUint16(offset + (i * 12) + 8, little);
      }
    } else if((marker & 0xFF00) !== 0xFF00) {
      break;
    } else {
      offset += view.getUint16(offset, false);
    }
  }
  return -1;
}
```

**向きを補正して描画**

```js
function drawAdjustImage(cnvs, img, orientation) {
  var ctx = cnvs.getContext('2d');
  var dx = 0;
  var dy = 0;
  var dw;
  var dh;
  var deg = 0;
  var vt = 1;
  var hr = 1;
  var rad;
  var sin;
  var cos;
  
  cnvs.width = (orientation >= 5) ? img.height: img.width;
  cnvs.height = (orientation >= 5) ? img.width: img.height;
  
  switch(orientation) {
    case 2: // flip horizontal
      hr = -1;
      dx = cnvs.width;
      break;
    case 3: // rotate 180 degrees
      deg = 180;
      dx = cnvs.width;
      dy = cnvs.height;
      break;
    case 4: // flip upside down
      vt = -1;
      dy = cnvs.height;
      break;
    case 5: // flip upside down and rotate 90 degrees clock wise
      vt = -1;
      deg = 90;
      break;
    case 6: // rotate 90 degrees clock wise
      deg = 90;
      dx = cnvs.width;
      break;
    case 7: // flip upside down and rotate 270 degrees clock wise
      vt = -1;
      deg = 270;
      dx = cnvs.width;
      dy = cnvs.height;
      break;
    case 8: // rotate 270 degrees clock wise
      deg = 270;
      dy = cnvs.height;
      break;
  }
  rad = deg * (Math.PI / 180);
  sin = Math.sin(rad);
  cos = Math.cos(rad);
  ctx.setTransform(cos * hr, sin * hr, -sin * vt, cos * vt, dx, dy);
  
  dw = (orientation >= 5) ? cnvs.height : cnvs.width;
  dh = (orientation >= 5) ? cnvs.width : cnvs.height;
  ctx.drawImage(img, 0, 0, dw, dh);
}
```

検証には[UIImageOrientation / EXIF orientation sample images - Matt Galloway](http://www.galloway.me.uk/2012/01/uiimageorientation-exif-orientation-sample-images/)にある画像を使うと便利。

<p data-height="362" data-theme-id="0" data-slug-hash="jxozaw" data-default-tab="result" data-user="hal9188" data-embed-version="2" data-pen-title="get EXIF orientation" class="codepen">See the Pen <a href="https://codepen.io/hal9188/pen/jxozaw/">get EXIF orientation</a> by Haruumi Kondo (<a href="https://codepen.io/hal9188">@hal9188</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# canvasに描画可能なピクセル寸法の上限

canvasには描画できるピクセル寸法の上限が環境によってある様子。
調べた限り、下記の環境ではcanvasの幅/高さを制限しておく必要がある。

| ピクセル寸法上限 | 環境 |
|--:|---|
| 2048 x 2048 | IE |
| 2560 x 2560 | iOS 8.0 or 8.1<br>iPod touch 5(iOS 8+) |
| 4096 x 4096 | iOS |

参考：

- [iOSにおけるリソース制限 - console.lealog();](https://lealog.hateblo.jp/entry/2013/03/21/212608)
- [javascript - Maximum size of a <canvas> element - Stack Overflow](https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element)
- [Canvas Max Size?](https://social.msdn.microsoft.com/Forums/en-US/53c368e5-042b-4c3f-8903-ee024f4f7933/canvas-max-size?forum=winappswithhtml5)

# toBlobメソッドの実装差異

canvasには画像を含んだBlobオブジェクトを生成するのに`toBlob`メソッドが標準で用意されているが、サポートしていないブラウザや独自のメソッドを用いてるブラウザもあるため、その差異を吸収できるように実装する必要がある。

```js
function toBlob(callback, cnvs, type, quality) {
  if(cnvs.toBlob) {
    cnvs.toBlob(callback, type, quality);
  } else if(cnvs.msToBlob) { // IE10~, EDGE
    callback(cnvs.msToBlob());
  } else { // toBlobが無い場合はpolyfillによって実行
    var binStr = atob(cnvs.toDataURL(type, quality).split(',')[1]),
      len = binStr.length,
      arr = new Uint8Array(len),
      blob;
    for(var i = 0; i < len; i++) {
      arr[i] = binStr.charCodeAt(i);
    }
    
    try {
      blob = new Blob([arr.buffer], {type : type || 'image/png'});
      callback(blob);
    } catch(e) {
      try {
        blob = new (window.MozBlobBuilder || window.WebKitBlobBuilder || window.BlobBuilder)();
        blob.append(arr.buffer);
        callback(blob.getBlob());
      } catch(e) {
        alert(e);
      }
    }
  }
}
```

# Android < 4.4でBlobに画像を積んで送信できないバグ

Blobオブジェクトを用いると画像データをバイナリ形式のまま送れるが、Android 4.0.x、4.1.xではBlobを非同期通信で送るとbodyが空になってしまうバグが存在する。

> XHR2 allows us to send additional formats with AJAX beside FormData, like Blob or ArrayBuffer. We can simply use the send method with our Blob as the parameter.
>
> While the request works as expected on supporting desktop browsers, there seem> s to be a bug in Android that sends the request completely empty. Here’s a test for xhr.send(blob) using GitHub’s API, which sends both a Blob and an ArrayBuffer.
> 
> *[Send a JPEG Blob with AJAX on Android](https://ghinda.net/article/jpeg-blob-ajax-android/)より引用*

※[Trying to send a Blob with XHR2 sends the request with an empty body on Android 4+](https://github.com/scottjehl/Device-Bugs/issues/34)では**4.0.x、4.1.xで再現すると言っているが、実機で検証したところ4.3でも再現した。**OSをアップデートしてもバグが直らない場合があるのかも知れない。
全ての実機検証は難しいので、4.1~4.3(Jelly Bean)と4.4(KitKat)で大きく変わっていることを踏まえ、とりあえず**Android 4.4未満で再現しうる**と考えた方が良さそう。

これを回避するには画像データをBlobオブジェクト以外の形式にして送れば良い。
`canvas.toDataURL`メソッドを使えばbase64形式で画像データを出力できる。