canvasを指定ファイルサイズに収まるように解像度を落として保存する

#前置き
[こちらの記事](https://qiita.com/lt900ed/items/3a222a7ca54917b5afff)の続き。

#やりたかったこと
canvas上で生成した画像を、wikiwikiのアップロード可能ファイルサイズ512KBに収まるようにサイズを落として保存したかった。
サイズの落とし方としてjpgの品質を下げる方法と解像度を下げる方法が思いついたが、ブロックノイズだらけになるくらいならQVGAとかになる方がましだと思ってるので後者を採用した。
512KBにぴったり合わせるのではなく、ざっくり下回っていればヨシとした。

#開発環境
Mery(x86) 2.5.6
Firefox 78.0.2(64ビット)

#結果
##コード

```javascript:SaveReduced
function SaveReduced(canvas_src){
	console.debug('Saving reduced image.');
	//出力用canvas生成
	const canvas_out = document.createElement('canvas');
	const ctx_out = canvas_out.getContext('2d');

	//入力canvasのファイルサイズを計測
	const filesize_src = base64ToFile(canvas_src.toDataURL("image/jpeg", 0.90))["size"]
	console.debug('filesize_src: ' + filesize_src + 'B');

	//制限サイズを設定
	const filesize_cap = 5120000;
	console.debug('filesize_cap: ' + filesize_cap + 'B');

	//縮小率を計算、制限サイズより小さければ縮小なし
	let rate_reducing = 1
	if (filesize_src > filesize_cap){rate_reducing = filesize_cap / filesize_src}

	//出力用canvasの解像度を縮小率に合わせて縮小し入力canvasを書き出し
	canvas_out.width = Math.floor(canvas_src.width * Math.sqrt(rate_reducing));
	canvas_out.height = Math.floor(canvas_src.height * Math.sqrt(rate_reducing));
	console.debug('canvas_out_size(w*h): ' + canvas_out.width + ' * ' + canvas_out.height);
	ctx_out.drawImage(canvas_src, 0, 0, canvas_src.width, canvas_src.height, 0, 0, canvas_out.width, canvas_out.height);
	
	//制限サイズを切るまで10%ずつ解像度を落とす
	while (base64ToFile(canvas_out.toDataURL("image/jpeg", 0.90))["size"] > filesize_cap){
		canvas_out.width = Math.floor(canvas_out.width * 0.9);
		canvas_out.height = Math.floor(canvas_out.height * 0.9);
		console.debug('canvas_out_size(w*h): ' + canvas_out.width + ' * ' + canvas_out.height);
		ctx_out.drawImage(canvas_src, 0, 0, canvas_src.width, canvas_src.height, 0, 0, canvas_out.width, canvas_out.height);
	}

	//アンカータグ経由でダウンロード
	GeneratedDownloadAnker(canvas_out.toDataURL('image/jpeg', 0.90),'output.jpg');
}

function GeneratedDownloadAnker(base64, name){
	//アンカータグを生成しhrefへBase64文字列をセット
	const a = document.createElement('a');
	a.href = base64;

	//ダウンロード時のファイル名を指定
	a.download = name;

	//クリックイベントを発生させる
	a.click();
}

function base64ToFile(data){
	try{
		let separetedDate = data.split(',');
		let mimeTypeData = separetedDate[0].match(/:(.*?);/);
		let mimeType = Array.isArray(mimeTypeData) ? mimeTypeData[0] : '';
		let decodedData = atob(separetedDate[1]);
		let dataLength = decodedData.length;
		let arrayBuffer = new ArrayBuffer(dataLength);
		let u8arr = new Uint8Array(arrayBuffer);
		for( let i = 0; i < dataLength; i +=1){
			u8arr[i] = decodedData.charCodeAt(i);
		}
		return new Blob([u8arr] , {type:mimeType});
	}catch (errors){
		console.log(errors);
		return new Blob([])
	}
}
```

##画面
512KBを指定した場合→433KBまで削減
![2020-07-24_205809.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679781/ea4f63b0-32ad-48d2-abde-9d1fe2fe8053.png)
320KBを指定した場合→298KBまで削減
![2020-07-24_205825.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679781/30ea0872-5e55-351f-20b5-2106830411e7.png)
いい感じに実装できた。

#苦労したところ
##canvasのファイルサイズが分からん
ファイルサイズを落とそうにも元々のサイズが分からなかった。
元々のサイズの取得はcanvasをバイナリ化した上でblobオブジェクトに変換し、sizeプロパティを読み込むらしい([参考](http://matz.hatenablog.jp/entry/2017/05/01/235824))。
バイナリ化は`canvas.toDataURL("image/jpeg", 0.90)`で実装。
jpgの品質が90なのは見た目とファイルサイズのバランスが良いと俺の中で評判だから。
blobオブジェクトへの変換は`base64ToFile()`関数で実装。[こちら](https://qiita.com/masash49/items/b01d0205b437a8d1ec72)の丸コピ。

##ファイルサイズが上手く下回らない
最初はファイルサイズと解像度は正比例していることを前提において、元のサイズと制限サイズの比率に応じて解像度を落とせばいい(比率の平方根を縦と横にそれぞれかける)と思っていた。
↓がそれを実装している部分。

```javascript
//出力用canvasの解像度を縮小率に合わせて縮小し入力canvasを書き出し
canvas_out.width = Math.floor(canvas_src.width * Math.sqrt(rate_reducing));
canvas_out.height = Math.floor(canvas_src.height * Math.sqrt(rate_reducing));
console.debug('canvas_out_size(w*h): ' + canvas_out.width + ' * ' + canvas_out.height);
ctx_out.drawImage(canvas_src, 0, 0, canvas_src.width, canvas_src.height, 0, 0, canvas_out.width, canvas_out.height);
```

しかしこれだけだとほとんどの場合制限サイズを下回らなかった。
仕方ないので↑の縮小を実施した後に、確実に制限サイズを下回るまで少しずつ縮小することにした。

```javascript
//制限サイズを切るまで10%ずつ解像度を落とす
while (base64ToFile(canvas_out.toDataURL("image/jpeg", 0.90))["size"] > filesize_cap){
	canvas_out.width = Math.floor(canvas_out.width * 0.9);
	canvas_out.height = Math.floor(canvas_out.height * 0.9);
	console.debug('canvas_out_size(w*h): ' + canvas_out.width + ' * ' + canvas_out.height);
	ctx_out.drawImage(canvas_src, 0, 0, canvas_src.width, canvas_src.height, 0, 0, canvas_out.width, canvas_out.height);
}
```

単に縮小するだけなら比率に基づく縮小なしにこのwhile文による縮小だけで要件は満たすが、端からこのwhile文にcanvasを突っ込むと周回数が多くなり負荷になりそうだったので結局両方使っている。
比率に基づく縮小でざっくり落としてwhile文で細かく調整しているイメージ。

##### 追記 2020/7/25
縮小される過程のログ。
①縮小前810KBくらいの場合。
![2020-07-24_212626.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679781/4bea6a9b-a097-0c6f-fc5f-d5d5fcda475b.png)
比率で1366*1191pxまで落として、while文で1回縮小している。

②縮小前13.6MBくらいの場合。
![2020-07-24_212552.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679781/3ffc9ca4-58e8-6df2-4c66-d9922d0d2a09.png)
比率で2844*1264pxまで落とした後while文が5回も回っている。`filesize_cap / filesize_src`が小さいほど、ファイルサイズの比率と解像度の比率の乖離が大きい模様。

##参考
https://qiita.com/geek_duck/items/2db28daa9e27df9b861d
https://maku77.github.io/js/canvas/auto-resize.html
http://javascriptist.net/ref/Math.min.html
https://murashun.jp/blog/20191110-53.html
https://w3g.jp/blog/intermittent_event_load_reduce
http://matz.hatenablog.jp/entry/2017/05/01/235824
https://blog.ver001.com/canvas_save_file/
https://tech.chick307.com/2014/04/26/javascript-split-ext/
https://itsakura.com/js-number
https://qiita.com/masash49/items/b01d0205b437a8d1ec72