dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

JavaScript(ES2015&React)で画像を扱う:リサイズとプレビュー表示

はじめに

web サイト/アプリケーションで画像のアップロード機能などを実装する場合、
最近のスマホのカメラで撮影した画像はサイズが数 MB にも及ぶので、あらかじめクライアント側で送信可能なサイズまで縮小する必要があります。
今回はそのような画像のリサイズの実装方法を整理しつつ、サンプルコードを React で書いてみます。

冒頭に画像のリサイズについて色々書いてますが、結論としては便利なライブラリでよしなにやってくれるので実装方法だけ知りたい場合は読み飛ばしてください。


JavaScript で画像を扱う際の基本

まず、JavaScript で画像ファイルを扱うにあたっての非常に基本的な話。
<input type="file"> 要素で選択した画像ファイルのデータを取得し、プレビュー表示するには以下のようにします。

class ImagePreviewer extends React.Component {
  constructor(props) {
    super(props);

    this.onImageSelected = this.onImageSelected.bind(this);
  }

  onImageSelected(e) {
    const file = e.target.files[0];
    const fr = new FileReader();

    fr.onload = () => {
      const imgNode = this.refs.image;
      // fr.onload = (event) => { ... } として
      // event.target.result で取る方法もある
      imgNode.src = fr.result;
    };
    fr.readAsDataURL(file);
  }

  render() {
    return (
      <div>
        <input type="file" accept="image/*" onChange={this.onImageSelected} />
        <p>Image will be previewed here!</p>
        <img ref="image" src="" />
      </div>
    );
  }
}

ファイル選択ボタンが押されて画像が選ばれたときに onChange イベントが発火するので、そのイベントハンドラ onImageSelected 内で以下のような処理を行っています。

  • 引数のイベントオブジェクトから対象のファイルを取得
  • FileReader オブジェクトを生成
  • FileReader オブジェクトの onload() を定義
    • onload() は readXXX で画像の読み込みに成功したときに呼び出される関数
  • FileReader.readAsDataURL() を呼び出し、画像を Data URL 形式で読み込む
  • Data URL 形式の画像をそのまま img タグの src 属性に指定する

Data URL とは

data:image/png;base64,iVBORw0KGgoAAAAN...

のような形式の文字列で、画像データを Base64 エンコードしたもので、
RFC2397 で仕様がきちんと定められているそうです。

data:[<mediatype>][;base64],<data>

また、この Data URL を <img src="..."> に指定することで画像のプレビュー表示も可能になります。

このあたりの話は以下の記事が参考になりました。


JavaScript で画像のリサイズ、の難しさ

単純に画像を読み込むだけなら上記の実装でよく、また、この後アップロードする場合も基本は Data URL 形式の画像データをサーバー側に POST してやれば良いです。

ただし、上述したように最近のスマホで撮影した画像などはそのままだと数 MB ものサイズになってしまうため、クライアント側でリサイズしてから送信してやる必要があります。

調べてみると、画像のリサイズには JavaScriptCanvas オブジェクトを使った方法があるそうです。

参考

先ほどのコードの onload メソッド内を以下のように修正します。

fr.onload = () => {
  console.log('before resizing: ', fr.result.length);
  // Image オブジェクトに src を指定すると、元画像の width と height が取れる
  const img = new Image();
  img.src = fr.result;
  const width = img.width;
  const height = img.height;
  console.log('width, height = ', width, height);
                                                                              
  // 縮小後のサイズを計算。ここでは横幅 (width) を指定
  const dstWidth = 1024;
  const scale = dstWidth / width;
  const dstHeight = height * scale;
                                                                              
  // Canvas オブジェクトを使い、縮小後の画像を描画
  const canvas = document.createElement('canvas');
  canvas.width = dstWidth;
  canvas.height = dstHeight;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, dstWidth, dstHeight);
                                                                              
  // Canvas オブジェクトから Data URL を取得
  const resized = canvas.toDataURL('image/jpeg');
  console.log('after resizing: ', resized.length);
  console.log('width, height = ', dstWidth, dstHeight);
                                                                              
  const imgNode = this.refs.image;
  imgNode.src = resized;
};

元画像の width, height を取得するため、一旦 Data URL から Image オブジェクトを作成しています。
src をセットすると width, height が取れるようになります。

Canvas オブジェクトは、Context オブジェクトを取得しそれに対して描画命令を行うことで描画が可能です。
drawImage() メソッドについては、こちらのリンクがわかりやすいです。
http://www.html5.jp/canvas/ref/method/drawImage.html

最後に canvas.toDataURL() メソッドを使って、リサイズ後の画像を Data URL 形式に戻しています。
toDataURL() の引数には MIME タイプ(参考)を指定します。jpg なら image/jpeg です。


リサイズはできた。が、まだ問題が。。。

画像のサイズを小さくすることはできました。
が、これでもまだ問題があります。

上記のコードを使い、スマホで撮影した画像を使ってプレビュー表示すると
以下のように画像が回転して表示されることがあります。

f:id:dackdive:20160717010038p:plain

これは、JPEG 画像の Exif タグに含まれる Orientation 情報を考慮していないからです。
Exif タグとは、ざっくり言うと画像を撮影した時の様々な情報を保持したメタデータです。

参考

また、Orientation は画像の方向を 1 ~ 8 までの数字で表したものですが、数字と方向の対応関係については以下が参考になります。
JPEGのExifタグ情報のOrientaionの定義の早見表 · DQNEO起業日記

Mac の場合、プレビュー.app で ツール > インスペクタを表示 を開き、2 つめのメニューの「一般」から確認することができます。

f:id:dackdive:20160717014101p:plain

というわけで、きちんと画像を扱うためには

  1. 読み込んだ画像から Exif を取得し、さらに Orientation を取得
  2. Orientation を元に、正しく画像を回転

する必要があります。

画像の Exif 情報を扱うためのライブラリとして exif-js なんてのがあるようですが、このあたりで調査は力尽きてしまったので
ES2015 の import/export 形式で読み込めるのかとか Orientation を元に回転するという操作が簡単に行えるのかなどはわかっていません。


JavaScript-Load-Image はそのあたりを解決してくれるライブラリ

というわけで、スマホで撮影した画像を JavaScript で扱うのは色々と面倒であることがわかりました。
さらに調べてみると、上述したリサイズや回転なども含めた画像操作に便利なライブラリがありました。

https://github.com/blueimp/JavaScript-Load-Image

README に明記されてませんが、npm でインストールすることができ、かつ ES2015 の import/export 形式で使用することができます。
(npm のページは https://www.npmjs.com/package/blueimp-load-image

インストールは

npm install --save blueimp-load-image

で行います。

そして、このライブラリを使用して onload メソッドを修正したものがこちらです。

import loadImage from 'blueimp-load-image';

class ImagePreviewer extends React.Component {

  ...

  onImageSelected(e) {
    const file = e.target.files[0];
    loadImage.parseMetaData(file, (data) => {
      const options = {
        maxHeight: 1024,
        maxWidth: 1024,
        canvas: true
      };
      if (data.exif) {
        options.orientation = data.exif.get('Orientation');
      }
      loadImage(file, (canvas) => {
        const dataUri = canvas.toDataURL('image/jpeg');
        const imgNode = this.refs.image;
        imgNode.src = dataUri;
      }, options);
    });
  }

  ...

先に parseMetaData() を使って画像の Exif 情報を取得し、Exif があれば Orientation を取得しておきます。
(ドキュメントにきちんと書かれていませんが、exif.get(key) で Orientation 以外の Exif 情報も得られるようです)

また、loadImage() は

loadImage(file (or blob), callback, options)

というように第一引数に画像データ、第二引数に画像ロード後の処理、第三引数にオプションを指定します。
指定できるオプションは README に書かれています。
https://github.com/blueimp/JavaScript-Load-Image/blob/master/README.md#options

たったこれだけで、画像のサイズ・回転を考慮してロードすることができるようになりました。便利ですね。