mobile Safari でも巨大な (HTML5)Canvas を画像ファイル出力するまで

HTML5 Canvas の利点の一つに、 `HTMLCanvasElement.toBlob() or toDataURL()` によるクライアントサイドでの画像化があります。

ところが (canvasの面積の制限があり) iOSで画像が作れないことがありました。

## 要約
* mobile Safari の canvas 面積の制限は他の環境に比べて厳しい (288MB / 16,777,216px(4096px * 4096px 相当))
* 一枚の canvas に描けないから分割して `ImageData` を作って配列を貼り合わせて画像エンコーダ ([jpeg-js](https://github.com/eugeneware/jpeg-js)) に投げて解決した

[jpeg-js](https://github.com/eugeneware/jpeg-js) や [pngjs](https://github.com/lukeapage/pngjs) を知っていれば読むことはあまり無いです。

#### 出来ること:

* `toBlob()` が対応していない形式の画像が出力できるようになる
* mobile Safari で 4096px * 4096px を超える画像出力ができるようになる

#### 書いてないこと:

* モバイルブラウザでの canvas のメモリ消費軽減手法
* `getImageData()` を用いた HTML5 Canvas 画像処理ことはじめ

## mobile Safari の HTMLCanvasElement の面積の制限

リモートデバッグで mobile Safari が console に吐いている警告を読むに、 mobile Safari の canvas 面積の制限は厳しめです。

`Canvas area exceeds the maximum limit (width * height > 16777216).` とか `Total canvas memory use exceeds the maximum limit(288MB)` なんて言われてしまうので、あまり巨大な canvas を作ることができません。
<small>(`width * height > 16777216` は `4096px * 4096px` の面積に相当するので、通常の用途ではほぼ問題ありません)</small>

50cm * 50cm 300dpi 相当の canvas から画像を得ようとしたら前述の制限に引っかかって何も出なかったので無理やり画像ファイル化するまでの経緯です

## 回避策

画像ファイルを出力するために `HTMLCanvasElement.toBlob()` を使うとき、 mobile Safari の制限で巨大なcanvasが作れません。

回避策としては

* クライアントサイドでの描画を諦めてサーバーサイドで描画する
* canvasを分割して描画して画像をアップロードしてサーバーサイドで貼り合わせる
* 同じく分割して描画してクライアントサイドで貼り合わせる

描画バックエンドは canvas をそのまま使いたいのでサーバーサイドでの描画は却下(headlessブラウザを動かすのも辛そうです)
貼り合わせのためだけに画像のアップロード&ダウンロードが発生するのも嬉しくないです。
クライアントサイドでの貼り合わせも最終出力サイズのcanvasが作れないので難しいように思われます。

### 最終出力サイズのcanvasが作れない
クライアントサイドでの分割&貼り合わせを行うとき、画像ファイルの面積のcanvasが作れません。なので

* 描画した画像を貼り合わせて (`CanvasRenderingContext2D.drawImage()`は使えない)
* それを画像ファイル形式へ変換する (`toBlob()`は使えない)

という問題が出てきます。

#### HTMLCanvasElement.getImageData()

ところで、`CanvasRenderingContext2D.getImageData()` を使えば canvas のピクセルの生のデータを取得できます。

```typescript
interface ImageData {
  readonly data: Uint8ClampedArray;
  readonly height: number;
  readonly width: number;
}
```
いい感じですね。

`toBlob()` での操作は

「canvasの描画内容 -> 画像ファイルBlob への変換」

なので、巨大なcanvasの画像ファイル化は `getImageData()` を用いると

「分割した各canvasの描画内容[] --**toImageData()**--> ピクセルデータ[] --貼り合わせ--> ピクセルデータ --(何か)--> 画像ファイルBlob」

という風にして実現できそうです。

このとき、 `ImageData.data: UInt8ClampedArray // 要するにただのバイト列` が得られるので、縦に分割すると貼り合わせ操作は `TypedArray` の結合になり楽です。

試してみると iOS の Safari では 1 タブ毎に 1.4GB 程度メモリが確保できたので、ピクセルデータを配列で持っておく分には問題ありません。(制限の16777216平方ピクセルはRGBAでx4の64MBなので、使えるメモリを1GBとすると単純計算で1024÷64=16倍までの余裕ができたことになります)


#### jpeg-js によるピクセルデータ→画像ファイル形式への変換

> ピクセルデータ --(何か)--> 画像ファイルBlob

のミッシング・リンクを埋めるのが画像エンコーダです。今回は [jpeg-js](https://github.com/eugeneware/jpeg-js) を使います
> A pure javascript JPEG encoder and decoder for node.js

いい感じですね。

「バイト列(ピクセルデータ) -> バイト列(画像ファイル形式)」の変換ができれば何でもいいですが（ライブラリが何かの都合でバックエンドにcanvasを使っていないことは確認する必要があります）javascript のライブラリは本当になんでもありますね。

この最終段を入れ替えることで、wasmあたりでエンコーダを引っ張ってきたり、`toBlob()` が対応していない画像形式での出力をしたり、通常より圧縮率を高めたりいろいろ応用できるんじゃないでしょうか？

### コード

```typescript
import jpegJs from 'jpeg-js'

interface Size { width: number; height: number }
interface Offset { top: number; left: number }
type ViewPort = Size & Offset;

function splitSize(size: Size): ViewPort[] { ... } // ただの分割なので省略(縦分割だけにすると貼り合わせが楽)
function margeImageData(
  dstData: Uint8ClampedArray,
  srcData: ImageData,
  offset: Offset
) { ... } // ただのバイト列の操作なので省略 (Uint32Arrayに変換すると4チャンネルまとめて処理できて楽)

function imageDataToBlob (
  imageData: ImageData,
  type: 'image/jpeg' | 'image/png',
  quality?: number // toBlob() に合わせて 0.0 to 1.0
): Blob {
  const { data, width, height } = imageData
  switch (type) {
    case 'image/jpeg':
      const jpegData = jpegJs.encode(imageData, quality ? quality * 100 : undefined)
      return new Blob([jpegData.data], { type })
    case 'image/png': // 略
  }
}

function renderSomething(fullSize: Size, vp: ViewPort): HTMLCanvasElement {
  const { width, height, left, top } = vp
  const canvas = document.createElement('canvas')
  canvas.width = width
  canvas.height = height
  const ctx = canvas.getContext('2d')!
  ctx.translate(left, top) // Offset分ずらす
  ... // 必要な描画を行う。 fullSize はここで使われる想定
  return canvas
}

// 内容ここから
function largeCanvasToBlob(
  tooLargeSize: Size,
  render: (fullSize: Size, viewPort: ViewPort) => HTMLCanvasElement,
  options: { type: 'image/jpeg' | 'image/png', quality?: number }
): Blob {
  const { width, height } = tooLargeSize
  const { type, quality } = options
  const data = new Uint8ClampedArray(width * height * 4) // RGBA 4チャンネルなので *4

  // canvasを分割してそれぞれのviewPortについて描画して結合
  for (const vp of splitSize(tooLargeSize)) {
    const canvas = render(vp)
    const ctx = canvas.getContext('2d')!
    const imageData = ctx.getImageData(0, 0, vp.width, vp.height)
    margeImageData(data, imageData, vp)
    canvas.width = canvas.height = 0 // https://qiita.com/minimo/items/b724c6793f45aca5e6f5
  }
  return imageDataToBlob({ data, width, height }, type, quality)
}
```

## 利点と欠点
⭕クライアントサイドで canvas から直接出力できない画像の生成を行うことができる（面積の制限や未対応の画像形式）
❌遅い。リアルタイム描画には向かない
❌あまり大きな画像を生成しても意味がない ([Mobile Safariで大きなJPEG画像を表示すると画像が汚くなる](https://www.notice.co.jp/archives/1447) みたいな事があるらしい)
❌あまり大きな画像(> 1.4 GB 等)を出力しようとするとメモリが足りないので付け焼き刃でしかない

## links
* [Canvasの面積が広すぎてiPhoneのSafariだけ表示できなかった](https://qiita.com/segur/items/12cdbb04c07ab9a0c567)
* [safari(iOS12)で"Total canvas memory use exceeds the maximum limit"が出る場合の対処法](https://qiita.com/minimo/items/b724c6793f45aca5e6f5)
* [Maximum size of a \<canvas> element](https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element)
* [TypedArray](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray)
* [Blob](https://developer.mozilla.org/ja/docs/Web/API/Blob/Blob)