3 min read

Cross Originの画像をcanvasで読み込んでpngで吐き出す

Table of Contents

ここ最近、canvas の画像合成機能の実装をやっています。

同じドメインから配信されている画像であれば問題になることはありませんが、Cross Origin だと話は変わってきます。Cross Origin の場合、canvas 上への描画自体は問題有りませんが、toDataURL()で png を吐き出すことが出来ません。

さて、困りました。

結果としては、base64_encodeしたバイナリデータを流し込んで回避するのがよいのでは?という結論に達しました。

なお、本記事のコードは以下のサイトを参考にさせていただきました。

File API と Canvas でローカルの画像をアップロード → 加工 → ダウンロードする | Tips Note by TAM

ゴール

  • Corss Origin の画像を canvas 上に表示してtoDataURL()で png としてダウンロードする
    • 静的ファイルは CDN で別ドメインから配信するというのは比較的あるケースのはず

前提条件

  • (Apache | nginx)の設定は触りたくない

サイト内にある画像を canvas 上に表示する

ではまず基本中の基本、index.htmlを作って、canvas 上にただただ画像を表示してみます。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <canvas id="canvas"></canvas>
  </body>

  <script>
    const canvas = document.getElementById("canvas");
    const canvasWidth = 400;
    const canvasHeight = 300;

    // Canvasの準備
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;
    const ctx = canvas.getContext("2d");

    // Canvas上に画像を表示
    const img = new Image();
    img.src = "./sample.png";
    img.onload = function () {
      ctx.drawImage(
        img,
        0,
        0,
        canvasWidth,
        this.height * (canvasWidth / this.width),
      );
    };
  </script>
</html>

new Image()で Image Object を生成、srcに画像を当てます。onloadは読み込み完了時のコールバックなので、このコールバック内でdrawImage()で画像を流し込みます。

まぁこれだけだと普通にimgタグのsrcに当てるのと見た目変わらないですが、まぁ良しとします。

※以降、主要部分のコードのみ抜粋するので、適宜読み替えてください。

サイト内にある画像を canvas 上に表示して png で DL する

では続いて、canvas 上に描画した画像をダウンロードできるようにしていきます。drawImage()した後にtoDataURL()で base64 エンコードされた文字列を生成してaタグのhrefに張っていきます。

img.onload = function () {
  ctx.drawImage(
    img,
    0,
    0,
    canvasWidth,
    this.height * (canvasWidth / this.width),
  );

  const data = canvas.toDataURL();
  const dlLink = document.createElement("a");
  dlLink.href = data;
  dlLink.download = "download.png";
  dlLink.innerText = "DOWNLOAD";
  document.getElementById("result").appendChild(dlLink);
};

おっと、エラーが出ました。汚染されている canvas…?

Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

実はここまでの確認では、httpを介さず、file:///で確認していました。色々調べてみると、toDataURL()http越しに見ないとこういうエラーが出るようです。

ということでローカルサーバーを起動して確認してみると、無事にdownload.pngが落とせました(筆者は案件的に PHP なので、PHP のビルトインサーバーを使いました)。

Cross Origin の画像を canvas 上に表示して png で DL する

さて、ここからが本題です。

Image Object のsrcは普通のimgタグのsrcと同じように扱えるので、試しにいらすとやさんの「バックエクステンションのイラスト(男性)」(いつ使うん…)をsrcに当ててみます。

img.src =
  "https://1.bp.blogspot.com/-S1E5m-Eb3fk/XXXOy9-3aKI/AAAAAAABUy8/xghPEclrtxwS4AWcqlw6FIRw4rUkPknPgCLcBGAs/s1600/undou_back_extension_man.png";

おっと、また先ほどと同じ汚染されている canvas という Exception が出ました。

Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

なぜでしょうか?

そう、Cross Origin だから、です。

この挙動に関しては MDN1にもちゃんと記載があります。こちらのドキュメントを読むと、サーバー側でヘッダーにAccess-Control-Allow-Origin "*"をつけ、かつ、Image Object のcrossOriginプロパティにAnonymousを設定すれば良いようです。

試しにいらすとやさんの画像のヘッダーを覗いてみましょう。普通に curl するとバイナリが表示されてしまうので、ヘッダーのみ2表示してみます。

$ curl -D - -s  -o /dev/null https://1.bp.blogspot.com/-S1E5m-Eb3fk/XXXOy9-3aKI/AAAAAAABUy8/xghPEclrtxwS4AWcqlw6FIRw4rUkPknPgCLcBGAs/s1600/undou_back_extension_man.png
HTTP/2 200
access-control-expose-headers: Content-Length
etag: "v15337"
expires: Mon, 11 Nov 2019 04:29:29 GMT
content-disposition: inline;filename="undou_back_extension_man.png"
content-type: image/png
vary: Origin
access-control-allow-origin: *
timing-allow-origin: *
x-content-type-options: nosniff
datetime: Wed, 13 Nov 2019 15:50:01 GMT
server: fife
content-length: 312405
x-xss-protection: 0
age: 1078
cache-control: public, max-age=86400, no-transform
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000

おお、さすがいらすとやさん。access-control-allow-origin: *がついてますね。ということはcrossOriginプロパティを適切に設定してあげれば、toDataURL()できそうです。

ということで、

const img = new Image();
img.crossOrigin = "Anonymous";

これで OK ですね。無事にtoDataURL()で吐けました。

…で、終わりではありません。access-control-allow-origin: *がついてない画像はどうすればいいんでしょうか?

幸いこのブログの画像にはそんな設定はない()ので、これを使ってみます。

$ curl -D - -s  -o /dev/null https://www.nabeen.be/authors/admin/avatar_hu98e5db809a8a89fe276a87e5cfbe8704_121675_250x250_fill_q90_lanczos_center.jpg
HTTP/2 200
accept-ranges: bytes
cache-control: public, max-age=0, must-revalidate
content-length: 15900
content-type: image/jpeg
datetime: Wed, 13 Nov 2019 02:24:35 GMT
etag: "8f3523dcec1ffaaf42ec52850c48b0c9-ssl"
strict-transport-security: max-age=31536000
age: 49832
server: Netlify
x-nf-request-id: af154bdd-eb4a-4214-84f9-0deaa729e6d7-36473308

srcをこの URL に差し替えてみると、以下のエラーが発生しました。見事に CORS policy に引っかかってますね。ちょうどいいサンプルがあってよかったです。…よかったです。

Access to image at 'https://www.nabeen.be/authors/admin/avatar_hu98e5db809a8a89fe276a87e5cfbe8704_121675_250x250_fill_q90_lanczos_center.jpg' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

base64_encode して src に流し込む

まぁ通常であれば WEB サーバーの方でAccess-Control-Allow-Originを設定するのが常套手段だと思いますが、ちょっとわけあってそっちは触りたくない…という大人の事情があるケースも多いです。多いですね?

ではどうすればいいかというと、冒頭でも述べたとおり、base64_encode()することで回避できます。

やり方は色々あると思うんですが、ここでは簡単に PHP を埋め込んでエンコードしてあげます。

まず、index.htmlindex.phpにします。

次にどこか適当な場所でfile_get_contents()してbase64_encode()します。base64_encode()はバイナリそのものなので、srcに流し込めるようにdata:image/jpeg;base64,をつけてあげます(ここは拡張子に合わせて設定してください)。

<?php
    $img_base64 = "data:image/jpeg;base64," . base64_encode(file_get_contents('https://www.nabeen.be/authors/admin/avatar_hu98e5db809a8a89fe276a87e5cfbe8704_121675_250x250_fill_q90_lanczos_center.jpg'));
?>

これを JavaScript 側に埋め込みます(この書き方嫌いだけど、簡単に PHP から JavaScript に引き渡せるので、まぁツッコミはなしで)。

img.src = <?php echo "'" . $img_base64 . "'"; ?>;

これで Cross Origin の画像を canvas 上に表示、かつtoDataURL()で png の吐き出しまでできるようになりました。

優勝!

P.S.

おいおい、そんな軽々しくbase64_encode()していいんか?!というツッコミはあると思います。

そのツッコミはごもっともで、仮にデータ量だけに着目した場合、 PHP マニュアル3にも書いてあるとおり 33%も増えるので、パフォーマンス面を考えると、決してベストな選択肢ではなく苦肉の策だということは理解しています。

Base64 でエンコードされたデータは、エンコード前のデータにくらべて 33% 余計に容量が必要です。

ただ、今回のような制約がある場合、他に手段が見つからなかったのも事実。

もし他にいい手法をご存知の方がいたら、こっそり教えてもらえると幸いです。

Footnotes

  1. 画像とキャンバスをオリジン間で利用できるようにする - HTML: HyperText Markup Language | MDN

  2. cURL コマンドでレスポンスヘッダのみを取得する - Qiita

  3. PHP: base64_encode - Manual