PWA のクライアントサイドのみで手軽に画像をリサイズできるアプリを作ろうとした話

[テックタッチアドベントカレンダー](https://qiita.com/advent-calendar/2019/techtouch)15日目を担当する @terunuma です。
14日目は @ihiroky による [漏れのある抽象化の法則 - False Sharing](https://qiita.com/ihiroky/items/9ad1d266c2fe109653cc) でした。
めちゃくちゃ深くていい話なんですがメモリの図が出てくるところで一人勝手に学生時代を思い出したりでテンションが上がったりしてました。ファミコン世代だからなのか、ビット領域に何かが入るとかネットワークのパケットに何を入れてるとかそういうビットとプロトコルみたいな話を聞くときは何かのスイッチが入ってしまいます。本当に何の話だ。

今回はふと欲しくなったものを手軽に作れないかとチャレンジした表題の PWA アプリの話です。

## とりあえず、作ったもの

GitHub:
https://github.com/terunuma/image-resizer-with-html-canvas

実際のページ:
https://terunuma.github.io/image-resizer-with-html-canvas/

![Screenshot_Chrome_20191215-001922.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47880/06721816-901e-6a58-5029-e7e9a53517dc.png)

### これは何

画像のリサイズアプリです。任意の画像をアップロードして、固定のサイズもしくは指定したサイズにリサイズしてダウンロードすることができます。Web API の FileReader オブジェクトと canvas のみで実装しているので、外部と通信することなくオフラインのみで操作可能です。また、せっかくオフラインで完結しているので PWA としても利用できるようにしました。


### なぜ作ったの？

スマホで例えばスタンプがわりにちょっとした画像を送るときとか、小さい容量のままでも構わないものを送るときにそういえば簡単に変換できるものがないなーと思って作りました。
何も考えずにトリミングして送るだけでも1MBとかになってたりするので、Slack や Discord など画像の添付時に圧縮しないタイプのチャットツールなど外で利用しているときに速度や通信量が気になるので...。

### 制限事項

iOS/Android のブラウザ、および Android の PWA で動作します。
iOS の PWA では動きません（生成した画像がダウンロードできない）
また動作未確認ですが Firefox でも画像がダウンロードできないらしいです（表示されていない要素を click() できないらしい）

## コードの解説

### ブラウザの表示

```html:index.html
  <h2>1. 画像をアップロード</h2>
  <input type="file" name="image">
  <h2>2. 画像サイズを調整</h2>
  <div class="images">
    <div>
      <h3>16</h3>
      <canvas class="size16"></canvas>
      <a href="javascript:void(0);" class="download">保存</a>
    </div>
    <div>
      <h3>32</h3>
      <canvas class="size32"></canvas>
      <a href="javascript:void(0);" class="download">保存</a>
    </div>
    <div>
      <h3>64</h3>
      <canvas class="size64"></canvas>
      <a href="javascript:void(0);" class="download">保存</a>
    </div>
  </div>

  <!-- ... -->

  <div>
    <h3>custom</h3>
    <canvas class="manual"></canvas>
    <a href="javascript:void(0);" class="download">保存</a>
    <div>
      <span>width:</span>
      <input type="number" name="width" value="0">
      <span>height:</span>
      <input type="number" name="height" value="0">
    </div>
  </div>
```

画像をアップロードするための input file と、画像を表示する canvas、画像をダウンロードするためのリンクを設置しています。canvas は固定サイズを複数 (16, 32, 64...) と、数値を入力してサイズを指定できるものを一つ用意しました。

### FileReader で指定されたローカルの画像を canvas に読み込む

```js:index.html
  ;(() => {
    const inputElem = document.querySelector('[name="image"]')
    inputElem.addEventListener('change', event => {
      const file = (event.target.files && event.target.files[0])
        ? event.target.files[0]
        : null
      if (!file) return
      if (!file.type.match('image.*')) return
      const reader = new FileReader()
      reader.readAsDataURL(file)
      reader.addEventListener('loadend', event => {
        src = event.target.result
        if (!src) return
        drawImageToCanvas(src)
      })
    })
  })()
```

input に指定された画像ファイルを change イベントで拾って FileReader オブジェクトで読み込み、`drawImageToCanvas(src)` へ渡します。バリデーションとかは省略しています。
今回は画像ファイルがアップロードされる前提で作っているので `readAsDataURL(file)` で取得した result を src としてそのまま渡していますが、真面目に作るならファイルタイプのチェックや正常なファイルであるかのチェックがここで必要になります。

```js:index.html
  function updateImage(image, width, height) {
    currentImage.element = image
    currentImage.width = width
    currentImage.height = height
    function updateOne(canvasElem, width, height) {
      const context = canvasElem.getContext('2d')
      context.clearRect(0, 0, currentImage.width, currentImage.height)
      canvasElem.width = width
      canvasElem.height = height
      context.drawImage(
        image,
        0, 0, image.width, image.height,
        0, 0, width, height
      )
    }
    for (const size in canvasElems) {
      updateOne(
        canvasElems[size],
        (size !== 'manual') ? size : currentImage.width,
        (size !== 'manual') ? size : currentImage.height
      )
    }
  }


  function drawImageToCanvas(src) {
    const image = new Image()
    image.src = src
    image.addEventListener('load', () => {
      widthElem.value = 128
      heightElem.value = 128
      updateImage(image, 128, 128)
    })
  }
```

Data URL 形式で渡されてきた画像データを Image オブジェクトにセットし、読み込みが完了したら canvas に渡します。`load` イベントを介さずに canvas に渡そうとしても画像が読み込まれていないので何も出力できません。
今回は画像ファイルが読み込まれる度に canvas の表示内容を更新したかったので clearRect → drawImage を毎回行うようにしています。

### canvas の画像サイズを調整できるようにする

```html:index.html
  <div>
    <h3>custom</h3>
    <canvas class="manual"></canvas>
    <a href="javascript:void(0);" class="download">保存</a>
    <div>
      <span>width:</span>
      <input type="number" name="width" value="0">
      <span>height:</span>
      <input type="number" name="height" value="0">
    </div>
  </div>
```

```js:index.html
  ;(() => {
    function updateWidth(event) {
      currentImage.width = event.target.value
      reloadCurrentImage()
    }
    function updateHeight(event) {
      currentImage.height = event.target.value
      reloadCurrentImage()
    }
    widthElem.addEventListener('change', updateWidth)
    heightElem.addEventListener('change', updateHeight)
  })()

  function reloadCurrentImage() {
    updateImage(currentImage.element, currentImage.width, currentImage.height)
  }
```

input の change イベントで入力された数値を元に width, height を再計算して canvas を再描画します。updateImage は上述の canvas への描画で利用した関数です。（今読んで気付きましたが全ての canvas を更新してしまっているので、custom サイズのものだけで良いですね 😫 ）

### ダウンロードリンクを生成して画像をダウンロードできるようにする

```js:index.html
  ;(() => {
    const downloadLinkElems = document.querySelectorAll('.download')
    if (!downloadLinkElems.length) return
    const isSafari = /constructor/i.test(window.HTMLElement) || window.safari
    downloadLinkElems.forEach(elem => {
      elem.addEventListener('click', event => {
        const canvasElem = elem.parentNode.querySelector('canvas')
        if (!canvasElem) return
        const link = document.createElement('a')
        const data = canvasElem.toDataURL('image/png')
        link.download = (canvasElem.className)
          ? `image-${canvasElem.className}.png`
          : 'image.png'
        if (isSafari) {
          const blob = new Blob([data], { type: 'image/png' })
          link.href = window.URL.createObjectURL(blob)
          link.target = '_blank'
        } else {
          link.href = data
        }
        link.click()
      })
    })
  })()
```

「保存」リンクを設置し、クリックされた時点で隣にあるcanvasの画像を `toDataURL` で Data URL に変換してアンカー要素の href 属性にセットします。ファイル名を download 属性にセットして要素の click イベントを発火させると画像がダウンロードされます。

この動作は Mobile Safari では正常に動作しないため、Safari かどうかを判定して Blob を `createObjectURL(blob)` で変換して href に渡す、という処理に分けています。これをすることで別ウィンドウに画像を表示する→長押しでダウンロードできる、という挙動になるのですがPWAだと別ウィンドウが開けないので何も起きなくなります。このため冒頭に記載した制限事項となってしまいました。（敗北...。せっかく PWA にしたのに iOS で動かないのは片手落ちどころか両手落ちレベルなのでいずれ解消したいです）

この処理については参考文献にも載せた [FileSaver.js のコード](https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js) が大いに参考になりました。

ちなみに PC であれば例えば Chrome だと canvas 要素を右クリックして画像として保存できるので、そもそもダウンロード処理も不要だったりします。ここは動作させたいデバイスやブラウザに向けて大いに作り込む余地があります。

### PWA として保存できるようにする（manifest.json を設置する）

[Web App Manifest Generator](https://app-manifest.firebaseapp.com/) で manifest.json を生成し、index.html と同じ階層に設置します。同ページでアイコンも必要なサイズ分だけ生成できるので、適当な画像を準備して同じく設置します。

これだけで Android 向けには PWA としてホーム画面に保存・利用できるようになりますが、iOS だとステータスバーなどがブラウザの見た目のままなので meta タグで調整します。

```html:index.html
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="ImgResize">
  <link rel="apple-touch-icon" href="assets/images/icons/icon-152x152.png">
```

## デプロイ

GitHub Pages にデプロイします。ここまで作成したファイルを master ブランチに push し、GitHub にてリポジトリページの「Settings > Options > GitHub Pages」で source を master branch にするとブランチの内容がホスティングされます。

## 終わりに

canvas、PWA などあまり最近の物ではない技術ですが情報が蓄積されていて GitHub Pages とかで手軽にホスティングできたのでまた一つ手軽で便利な時代になったなと感心しました。
一方で canvas 周りの実装はうまくいったもののダウンロード処理のところが未完となり課題を残してしまいました。Safari 手強い。適当なところに API 立ててダウンロードできるようにするでもいいのですが、オフラインでさくさく動作する体験が気持ちいいのでなんとか iOS でも解消したいところです。
Android PWA だと非常にサクサク動くのでぜひホーム画面に保存して試してみてください。

明日は...おっ空いてる！  駆け込みか後日で埋まるかもしれませんね、乞うご期待です。
明後日は @mxxxxkxxxx の記事です、こちらもお楽しみに！

## 参考文献

load an image from an input file into canvas tag
https://gist.github.com/felixzapata/3684117

Scaling an image to fit on canvas
https://stackoverflow.com/questions/23104582/scaling-an-image-to-fit-on-canvas

Canvas 入門
https://qiita.com/tfrcm/items/c875e96453159eae44d7

PWAのmanifest.jsonとiconsの各サイズのアイコン画像を自動生成してくれるApp Manifest Generatorの紹介
https://qiita.com/shisama/items/d4d0b24980beaea57231

Web App Manifest Generator
https://app-manifest.firebaseapp.com/

FilaSaver.js （のコード）
https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js

mobile Safari でも巨大な (HTML5)Canvas を画像ファイル出力するまで
https://qiita.com/D01/items/bf2a150fd6f817a289aa

【Javascript】ボタンを押すとcanvasを画像としてダウンロード
https://qiita.com/lookman/items/d93dd62a41f17a4d2de8

HTML5 の canvas 要素を base64 文字列化し画像として保存する方法まとめ
https://qiita.com/clockmaker/items/924b5b4228484e7a09f0