別オリジンの画像をcanvasに読み込んでダウンロードする

この記事は[リンク情報システム](https://www.lis.co.jp/)の勝手に始める[「2021新春アドベントカレンダー Tech Connect」](https://qiita.com/dgkz/private/eabde6515457d8acec9b)のリレー記事です。
engineer.hanzomon のグループメンバによってリレーされています。
（リンク情報システムのFacebookは[こちら](https://ja-jp.facebook.com/lis.co.jp/)から）

# 概要
HTML要素の\<canvas\>は通常外部コンテンツを読み込むことができませんが、あることをすることで\<canvas\>が外部コンテンツを読み込めるようになります。
その方法の一例がMDN Web Docsの「画像とキャンバスをオリジン間で利用できるようにする」という記事で公開されています。
https://developer.mozilla.org/ja/docs/Web/HTML/CORS_enabled_image

ここで公開されている方法を元に「別オリジンの画像をキャンバスに読み込み、読み込んだ画像をファイルとしてダウンロードする」という機能を実装したのですが、その際にいくつか落とし穴にはまったので本記事ではその落とし穴について記載します。
本記事ではTypeScriptでコードを記述します。

# \<canvas\>とは

HTMLの要素の中には\<canvas\>要素という図形を描くための要素があります。
\<canvas\>は既存の画像を読み込むことができ、例えばインターネット上に公開されている画像を読み込んで何かしらの加工して表示をする、といったことができます。

しかし\<canvas\>は外部コンテンツの読み込みを通常は許可していないため、外部コンテンツを読み込むためには上記で紹介した記事のような手順を踏む必要があります。

必要な手順を大まかに言うと

1. 画像提供元が画像ファイルに対してオリジン間のアクセスを許可する
1. crossOrigin属性をAnonymousに設定した\<img\>（HTMLImageElement）で画像を読み込む
1. 上記の\<img\>を\<canvas\>（HTMLCanvasElement）に読み込ませる
1. 上記の\<canvas\>を\<a\>（HTMLAnchorElement）のdownload属性を用いてダウンロードする

という流れになります。
\<canvas\>は許可されている外部コンテンツを読み込むことができませんが、\<img\>は許可されている外部コンテンツを読み込むことができるので、一度\<img\>に読ませてから、その\<img\>を\<canvas\>が読み込むという手順を取ります。
その後、\<a\>の属性の1つであるdownload属性を用いて画像のダウンロードを行います。

なお、1.については基本的に自分でどうにかできる部分ではないので、画像ファイルに対してオリジン間のアクセスが許可されていなければ諦めるしかないです。

# 落とし穴1: IEとEdge従来版ではtoBlobがない

プログラム内で保持している画像データをダウンロードする場合、\<a\>のdownload属性を利用してダウンロードすることができます。
download属性を使ってダウンロードする場合、\<a\>のhref属性に指定するのが以下のいずれかである必要があります。

- 同一オリジンのURL
- blob:から始まるURL
- data:から始まるURL

HTMLCanvasElementにはtoBlob()というメソッドが用意されているため、これを使うことで\<canvas\>の画像データをBlobオブジェクトに変換することができます。このBlobオブジェクトをURL.createObjectURL()メソッドに渡すことでblob:から始まるURLが出力されるので、これを\<a\>のhref属性に指定します。
コードにすると以下のような感じになります。

```TypeScript
  const link = document.createElement("a");
  canvas.toBlob((blob: any) => {
    if (blob) {
      anchor.href = window.URL.createObjectURL(blob);
      anchor.download = "filename.jpg";
      anchor.click();
    }
  });
```

ですが、IEとEdge従来版ではtoBlob()メソッドがサポートされておらず、代わりにmsToBlob()というメソッドがサポートされています。そのため、ブラウザによって処理を分岐する必要があります。
IEとEdge従来版をサポートする場合は以下のようなコードになります。

```TypeScript
  if (typeof window.navigator.msSaveBlob === "function") {
    const blob = canvas.msToBlob();
    window.navigator.msSaveBlob(blob, "filename.png");    
  } else {
    const link = document.createElement("a");
    canvas.toBlob((blob: any) => {
      if (blob) {
        anchor.href = window.URL.createObjectURL(blob);
        anchor.download = "filename.png";
        anchor.click();
      }
    });
  }
```

Edgeのバージョン79以降はChromiumベースとなったことから、JavaScriptエンジンがv8となったためtoBlob()メソッドを使うことができます。代わりに、msToBlob()メソッドは使えなくなりました。

※msToBlob()メソッドに関するドキュメントは現在非公開となっています。幸い、InternetArchiveには残っていたので、以下のリンクからmsToBlob()メソッドの仕様を確認することができます。
https://web.archive.org/web/20140607232036/http:/msdn.microsoft.com/en-us/library/windows/apps/hh465735.aspx

# 落とし穴2: msToBlob()メソッドはpngフォーマットで返す

msToBlob()メソッドはドキュメント仕様に書かれている通り、pngフォーマットの画像データを返却します。
画像データをpngで保存する場合はこれで良いのですが、jpegなど別のフォーマットで保存したい場合はmsToBlob()メソッドを使うことができません。
そのため、HTMLCanvasElementがもつtoDataURL()メソッドを用いて、指定のフォーマットのデータURLにします。
その後、自前のBlob変換メソッドを用いてBlobオブジェクトを生成します。

```TypeScript
  saveImage() {
    const type = "image/jpeg";
    const uri = canvas.toDataURL(type, 0.85);
    if (typeof window.navigator.msSaveBlob === "function") {
      const blob = this.toBlob(uri, type);
      window.navigator.msSaveBlob(blob, filename);
    } else {
      canvas.toBlob((blob: any) => {
        if (blob) {
          const link = document.createElement("a");
          link.href = uri;
          link.setAttribute("download", filename);
          document.body.appendChild(link);
          link.click();
          link.remove();
          window.URL.revokeObjectURL(uri);
        }
      });
    }
  }

  /**
   * 画像のデータURIをBlobに変換する
   */
  toBlob(dataUri: string, type: string) {
    let bin = atob(dataUri.replace(/^.*,/, ""));
    let buffer = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) {
      buffer[i] = bin.charCodeAt(i);
    }
    let blob = new Blob([buffer.buffer], { type: type });
    return blob;
  }
```

# 完成コード

最終的なコードは以下のとおりです。

```TypeScript
  /** 保存時のファイル名 */
  filename: string = "";
  /** 外部コンテンツの読み込みに使用する<img>要素 */
  loadImg: HTMLImageElement = new Image(); 

  /**
   * 画像を読み込む
   */
  loadImage(url: string, filename: string) {
    this.filename = filename;
    this.loadImg = new Image();
    this.loadImg.crossOrigin = "Anonymous";
    this.loadImg.addEventListener("load", this.saveImage, false);
    // 以下を実行すると、loadイベントが起こりsaveImageメソッドが実行される。
    this.loadImg.src = url;
  }

  /**
   * 画像を保存する
   */
  saveImage() {
    let canvas = document.createElement("canvas");
    let context: CanvasRenderingContext2D | null = canvas.getContext("2d");
    // 画像サイズを設定し<canva>要素に画像データを読み込む
    canvas.width = this.loadImg.width;
    canvas.height = this.loadImg.height;
    if (context) {
      context.drawImage(this.loadImg, 0, 0);
      const type = "image/jpeg";
      const uri = canvas.toDataURL(type, 0.85);
      if (typeof window.navigator.msSaveBlob === "function") {
        // IEおよび従来版EdgeではtoBlobを使用できない。
        // またmsToBlobではpng形式でデータを返すため、独自のBlob変換処理を用いる。
        const blob = this.toBlob(uri, type);
        window.navigator.msSaveBlob(blob, this.filename);
      } else {
        // IE,従来版Edge以外はtoBlobを使用できるため、toBlobを用いてBlobへ変換する。
        canvas.toBlob((blob: any) => {
          if (blob) {
            // <a>要素のdownload属性を利用してファイルとして保存されるようにする。
            const link = document.createElement("a");
            link.href = uri;
            link.setAttribute("download", this.filename);
            document.body.appendChild(link);
            link.click();
            link.remove();
            window.URL.revokeObjectURL(uri);
          }
        });
      }
    }
  }

  /**
   * 画像のデータURIをBlobに変換する
   */
  toBlob(dataUri: string, type: string) {
    let bin = atob(dataUri.replace(/^.*,/, ""));
    let buffer = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) {
      buffer[i] = bin.charCodeAt(i);
    }
    let blob = new Blob([buffer.buffer], { type: type });
    return blob;
  }

```

# 参考

本記事の内容を実装するにあたり、以下の記事を参考にしました。

- 別ドメインの画像ファイルをローカルに保存する（React+canvas）
https://qiita.com/mooriii/items/39bb4c1fe948bf6207bf

- JavaScriptでcanvasをファイルに保存する方法（IE対応も）
https://blog.ver001.com/canvas_save_file/