Google Driveでインターネット上の画像をダウンロードして解像度を変える方法

# はじめに
[Google Apps Script](https://workspace.google.co.jp/intl/ja/products/apps-script/)(以降GASと表記)を使い、画像を解像度を変更してGoogle Driveに保存する方法を紹介します。

## GASってJavaScriptだよね
GASではJavaScriptを使ってマクロを書けます。

JavaScriptで解像度を変更するにはCanvasを使ってdrawImageを使うのが定石です。
実は以前個人Blogで使うために、Canvasを使って解像度を変更する処理を作ったことがありました。
[JavaScriptで解像度を変更してから画像をアップするアップローダ](https://github.com/kenz/modern_image_upoloader)
これを流用すればサクッと終わるぞ そう考えていました。

しかし、結論から言うとこの方法は使えませんでした。
というのも、CanvasはHTMLの要素で、GASはHTMLと連携して動くわけではないので、documentが使用できずCanvasを使うことも出来ないのです。
なんてこったい。

## まずはググってみる

GASを使用して解像度を変更する方法をググってみると [ImgApp](https://github.com/tanaikech/ImgApp)という[tanaikechさんが作られたライブラリが紹介](https://zenn.dev/yamo/articles/c0c6d51c7d625c)されていました
そこで、まずはImgAppを試してみることにしました。
# ImgAppを使ってみたけれど
ImgAppを使って処理を使ってみたところ、想定と異なる解像度が出力されることに気が付きました。
特に縦長の画像を処理する場合や画像のクロップを行う場合、高解像度の画像を出力したいときなどに、指定した値と異なるサイズの画像が生成されてしまいました。

##  ImgAppのコードを読んでみる。
ということで、ImgAppがどのように実装されているのかコードを読んでみます。
画像のクロップやリサイズを行う関数 `doResize()` と `editImage()` を読んでみました。

内部の処理は概ねこうなってました。
`doResize()` はGoogle Driveにファイルをアップロードして、そこから、Google Driveのサムネイルを取得しています。
Google DriveはファイルをアップロードされたファイルをWeb UI上でサムネイルとして表示する機能があり、この機能を使ってリサイズされた画像を取得出来るというわけ。
元の画像から決められたルールに基づいたURLを指定するだけでリサイズされた画像が取得でき、任意の解像度を指定することもできます。
`editImage()` はGoogle Drive上でSlideを作り、Slide上に画像を配置して、Crop処理や複数画像の重ね合わせなどを実行したうえで、サムネイルで画像を取得していました。

すごいことを考えるなぁ、と思うのと同時に
やはり、機能的な制限がいくつかあるようで、試しにサムネイル画像のパラメータw2400-h1200を指定してみると、結果として出力されたのは幅2251、高さ1125の画像でした。

どうやら、幅は2251、高さは1236px以上の画像は生成出来ないようです。
このあたりは、もともとサムネイル用として作られている機能なので致し方ないところな気もします。
今回はもっと高解像度な画像で保存したいのでImgApp以外の方法を考えることにしました。

## Blobデータを使えばリサイズできる？
GASでは画像データをBlobとして取得することも出来ます。
この機能を使えば理論上は解像度変換機能を使うこともできそうです。
けれども、問題はGASはそこまで性能が高くありません。
延々とバッチを流すような作りにすればそれも可能でしょうが、今回はGoogle Drive上でのユーザーの操作に対話的に反応させたかったのでもっと性能を求めたいです。

# 回避策
## クライアントのリソースを使おう
上記の問題を解決するため、クライアントのリソースを使うことにします。
今回はGoogleDrive上で対話的に実行することで、ユーザーのコンピューターリソースを使うことが出来るはずです。

## クライアントに処理を送り込む仕組み
GASのスクリプトはJavaScriptを元にしていますが、一般的なWebサイトに埋め込まれたJavaScriptのようにユーザークライアントで実行されるわけではなく、Googleのサーバー上で実行されます。

そのため、GASでユーザーのコンピューターリソースを使うためには、ユーザーにスクリプトを送り実行してもらう必要があります。
ところが、GAS上ではスクリプト（Googleサーバーで処理されるスクリプト）とHTML(ユーザーに表示されるHTML)しかなく、JSファイルを送ることは出来ません。

そこで、HTMLのインラインにJavaScriptを埋め込みます。

えっと…それが可能 だったら直接JavaScriptを置けても良くないですか？ Googleさん

Index.html
``` Html
<!DOCTYPE html>
<html>
 <body>
   <canvas id ="canvas"></canvas>
   <script>
     alert("Hello JavaScript!")
   </script
 </body>
</html>
```
GASでは `doGet()` の戻り値にHTMLを返すことで、Webサイトとして機能させられます。


resize.gs
```JavaScript
function resizeImage() {
 const template = HtmlService.createTemplateFromFile('index');
 return template.evaluate()
}
function doGet(){
 return resizeImage();
}
```
変数名をtemplateとしている理由については後で説明します。

NOTE:単体のWebアプリケーションではなくGoogleDocsやSpreadSheet内で実行する場合は使用する関数が変わるのでご注意ください。
たとえば、SpreadSheet内でHTMLを表示するときはdoGet()を使用せず次のようにします。
resize.gs
```JavaScript
SpreadsheetApp.getActiveSpreadsheet().show(html)
```

## resize処理の実装。
ユーザーのHTMLと連携して動くので今回はCanvasを使用することが出来ます。
今回は後述の理由により説明を省きます。
Canvasを使った解像度変更について知りたい人は次の記事を見てください。
[JavaScriptで解像度を変更してから画像をアップするアップローダ](https://github.com/kenz/modern_image_upoloader)

## CORS回避
さて、ユーザーに送りつけたJavaScriptで画像をダウンロードしようとするとここでまた一つの壁が立ちはだかります。CORSです。

CORSは(Cross-Origin Resource Sharing)の頭文字を取ったもので、異なるオリジン（ドメイン、プロトコル、ポート）のリソースを使用するための仕組みです。

例えば ユーザーがサイトA(今回はGoogle Drive)に訪問したときにサイトAのスクリプトが、ユーザーのブラウザに画像があるサイトBのリソースを取得させようとした場合、サイトBが明示的にサイトA経由からのリソース使用を許可していない場合、サイトBからはリソースのダウンロードが行われず、スクリプトが失敗します。

余計なことを、そんなもの無効にしてしまえ！ と思うかもしれませんが
CORSに関してサイトAには出来ることがありません。

なぜなら、CORSの仕組みは、サイトAからユーザーとサイトBを守るための仕組みだからです。

サイトAにあるスクリプトに従って、訪問したユーザーがサイトBのサーバーから画像5000兆枚取得する場合、ユーザーのクライアントは直接サイトBから画像を取得しようとするので、サイトAは自分の手を汚さずにユーザーのリソースを使ってサイトBを攻撃できます。

このような攻撃を避けるために、CORSではブラウザがサイトAの指示でサイトBのリソースを使用する前に、予めサイトBにサイトAがリソースを取ってこいって言ってるけれど使っていい？ と確認する仕組みになってます。

ユーザーのブラウザとサイトBの間でサイトAの認証を行うので許可される側のサイトAにはCORSの認証を回避するすべがありません。

CORSを使ってサイトBの画像を取得するには、本来ならばサイトBにCORSの設定をしてサイトAを許可するのが順当ですが、今回はサイトAに当たる部分がGoogleDriveなのが問題です。
GoogleDriveはユーザーでオリジンが分かれていません。
そのため、サイトBがサイトAを許可するとGoogleDrive全体を許可することになってしまいます。
GoogleDriveは一般に開かれたサービスなので全く関係のない第三者にサイトBのリソースを使うことを許可することになってしまいます。

ということで、今回はサイトBのCORS設定を行うことが出来ません。
そこで、別の手段を使います。

## ユーザーがだめならGoogleDriveが取りに行けばいいじゃない
CORSが禁止するのはサイトA（ここではGoogleDrive）がユーザーに、サイトB（ここでは画像があるサーバー）のリソースを取ってこらせることであって、サイトAが直接サイトBをアクセスするのを防ぐものではありません。

ユーザーに直接サイトBからダウンロードさせようとするからだめなのであって、
まずはGAS（Googleのサーバー）がサイトBから画像をダウンロードして、それをユーザーに送る方法を取れば問題ありません。

GASでは次のように書くことで外部URLのデータを取得することが出来ます。
resize.gs
```JavaScript
const imageBlob = UrlFetchApp.fetch(url);
```

## GAS経由でユーザーに画像をおくる
さて、問題はどうやってユーザーに画像を送るかです。
今回はHTMLにして画像を送ることにしました。
GASのHTMLはテンプレートとして一部を差し替えることができます。

GAS上で `createTemplateFromFile()` で作成したテンプレートに値を設定します。

resize.gs
```JavaScript 
function resizeImage() {
 const template = HtmlService.createTemplateFromFile('index');
 template.image = “dummy text”
 return template.evaluate()
}

HTMLの差し替える場所を次のように記載します。
index.html
``` html
<?= image?>
```
ただし、ここで渡せるのはStringのみで、画像データを渡すことは出来ません。
そこで、画像をbase64でテキスト化してhtml内に文字列として埋め込みます

resize.gs
```JavaScript
function resizeImage() {
 const template = HtmlService.createTemplateFromFile('index');
 const url = <YourImageURL>;
 const imageBlob = UrlFetchApp.fetch(url).getBlob();
 const contentType = imageBlob.getContentType();
 const base64Data = Utilities.base64Encode(imageBlob.getBytes());
 const base64Url = "data:" + contentType + ";base64," + base64Data;
 template.image = base64Url;
 return template.evaluate()
}
function doGet(){
 return resizeImage();
}
```


HTML側ではJavaScriptでbase64をImageのsrcに設定することで画像データとして取り扱うことが出来ます。

index.html
``` JavaScript
function resize(base64){
  const image = new Image();
  image.src = base64;
}
```
リサイズ処理については先ほど紹介した記事を見てもらうとして、
手っ取り早く1920,1080のサイズにリサイズする場合は、canvasサイズとdrawImageでwidthとheightを指定します。

index.html
```html
<!DOCTYPE html>
<html>
 <body>
   <canvas id ="canvas"></canvas>
   <script>
     function resize(base64){
       image = new Image();
       image.src = base64;
       const width = 1920;
       const height = 1080;
       const canvas = document.getElementById("canvas");
       canvas.width = width;
       canvas.height = height;
       const context = canvas.getContext("2d");
       context.drawImage(image, 0, 0, width, height);
     }
     resize(<?= image?>);
   </script
 </body>
</html>
```

imageには元画像のwidthとheightも取得できるので
等倍で拡大したいという場合は次のよう行うことも出来ます。

index.html
```JavaScript
        image = new Image();
        image.src = base64;
        const width = image.width * 2;
        const height = image.height * 2;
```

# 処理結果をGoogleDriveに送る
クライアントでのリサイズが終わったらその結果をGoogleDriveにアップロードします。
拡大縮小した画像をGASで引き続き使うためにはGSファイルに縮小された画像を返す必要があります。

HTML内で`google.script.run.` に続けてGASの関数を指定することで、GAS上の関数を呼ぶことが出来ます。
このとき受け渡せる引数はテキストデータという制限があるので、クライアントに送りつけたときと同様に画像データをbase64でテキスト化します。

canvasの `toDataURL()` を呼びbase64を取得して、GASの `postImage()` 関数に渡すには次のようにします。

index.html
```javascript
resizedBase64 = canvas.toDataURL("image/jpeg");
google.script.run.postImage(resizedBase64);
```


# GAS側で結果を処理する。
GASに先程HTMLの`google.script.run.` で指定した名前(postImage())で関数を作り、受け取ったリサイズ済みの画像をGoogle Driveに保存します。
base64のうち画像データ分を取得するためにカンマ区切りの[1](2つめの要素)を取得し、Utilities.base64Decode()に渡すことで画像データを取得できます。

resize.gs
```javascript
function postImage(postItem){
 var base64 = postItem.split("base64,")[1];
 var imageData = Utilities.base64Decode(base64);
```

画像データをGoogleDriveに保存するには
`Utilities.newBlob()` を使ってblobを作った上で、 `getAs(MineType.JPEG)`  でJPEGとして取得します。
`setName` で保存するファイル名を指定します。

resize.gs
```javascript
 var blob =  Utilities.newBlob(imageData, MimeType.JPEG);
 const jpeg = blob.getAs(MimeType.JPEG);
 jpeg.setName("resized_image.jpg");
```
GoogleDriveの指定したフォルダにファイルを格納するには、次のようにFolderを取得した上でcreateFileを指定します。

resize.gs
``` javascript
DriveApp.getFolderById(<folderId>).createFile(jpeg));   
```

ここで指定する `folderId` は
Google Driveで該当のフォルダをブラウザで開いたときにURLから取得することが出来ます。
`https://drive.google.com/drive/folders/<folderId>`


# 完成
これでインターネット上のフォルダを好きなサイズにリサイズしてGoogleDriveに格納するGASが完成しました。
スクリプトは全体は次の通り
resize.gs
```javascript
function resizeImage() {
 const template = HtmlService.createTemplateFromFile('index');
 const url = <YourImageURL>;
 const imageBlob = UrlFetchApp.fetch(url).getBlob();
 const contentType = imageBlob.getContentType();
 const base64Data = Utilities.base64Encode(imageBlob.getBytes());
 const base64Url = "data:" + contentType + ";base64," + base64Data;
 template.image = base64Url;
 return template.evaluate();
}
function doGet(){
 return resizeImage();
}
function postImage(postItem){
 var base64 = postItem.split("base64,")[1];
 var imageData = Utilities.base64Decode(base64);
 var blob =  Utilities.newBlob(imageData, MimeType.JPEG);
 const jpeg = blob.getAs(MimeType.JPEG);
 jpeg.setName("resized_image.jpg");
 DriveApp.getFolderById(<folderId>).createFile(jpeg);   
}
```


index.html
```Html
<!DOCTYPE html>
<html>
 <body>
   <canvas id ="canvas"></canvas>
   <script>


     function resize(base64){
       image = new Image();
       image.src = base64;
       const width = image.width * 2;
       const height = image.height * 2;
       const canvas = document.getElementById("canvas");
       canvas.width = width;
       canvas.height = height;
       const context = canvas.getContext("2d");
       context.drawImage(image, 0, 0, width, height);
       resizedBase64 = canvas.toDataURL("image/jpeg");
       google.script.run.postImage(resizedBase64);
     }
     function success(result){
       console.log("success");
       google.script.host.closeDialog();
     }
     function fail(fail){
       console.log("fail");
       console.log(fail);
     }


     resize(<?= image?>);
   </script
 </body>
</html>
```


# 注意
この処理にはいくつか注意点があります。
リサイズ処理はクライアントのリソースを使用するためユーザーがブラウザを閉じてしまうと処理が中断してしまいます。
GASは通常サーバーサイドで動くため指定された時間に定期的に動く仕組みなどもありますが、上記の理由から使用することが出来ません。

また、GoogleDriveにおけるファイル名の概念は一般的なPCの概念とは異なり、同じフォルダに同じファイル名が複数存在することが許容されます。
もし、上記の処理を連続で実行すると、上書きされるのではなく、同じ名前のファイル名が複数保存されます。