Google Apps ScriptとGoogleスライドを使って画像を合成をする

Google Apps Scriptを使って、2つの画像を1つの画像に合成してみました。[見積書に押印する](https://qiita.com/takatama/items/5b87ed7e2bff77459b1d)（見積書画像に、押印画像を重ね合わせる）のに使うつもりです。

JavaScriptで画像を合成するにはcanvasが便利ですが、 ~~ウェブブラウザー上でしか使えません。~~ 今回はサーバー側で合成したかったので、GoogleスライドをApps Scriptで操作することで実現しました。一時的なスライドを作り、そこに2つの画像を挿入後、1つの画像として出力します。

2023/04/02 追記：
後日、Google Apps Script側でcanvasを含むHTMLを作成し、それをPDFとして保存すれば、2つの画像を合成できることが分かりました。

https://qiita.com/takatama/items/2b73767f8d30477bf290

2023/04/08 追記：
後日、[DocsServiceApp](https://github.com/tanaikech/DocsServiceApp)を使うことで、サイズを指定したGoogleスライドを作成できることが分かりました。pptxファイルを作って、変換しているようです。また[Slides.Presentations.Pages.getThumbnail](https://developers.google.com/slides/api/reference/rest/v1/presentations.pages/getThumbnail?hl=ja)を使えば、Apps Scriptの実行を終了させることなくサムネイル画像を生成できます。

https://tanaikech.github.io/2020/10/13/inserting-text-on-image-using-google-apps-script/

# DocsServiceAppによる実現方法

Google Apps Scriptを使って、ある画像baseImageに別の画像stampImageを重ね合わせて、新しい画像combinedImageを作ります。baseImageとstampImageはGoogleドライブに保存されています。

## 事前準備

サービスに`Drive API`と`Google Slides API`を追加します。

ライブラリーに[DocsServiceApp](https://github.com/tanaikech/DocsServiceApp)を追加します。Script IDは 
 `108j6x_ZX544wEhGkgddFYM6Ie09edDqXaFwnW3RVFQCLHw_mEueqUHTW` です。

## 実装

```javascript
const baseImageId = '1wUip1VniS0ZsoBn-0D-F9ZXbbDrWsV8d';
const stampImageId = '1VadRiRqNG_OgRrz3uVdgnevUoQZ8EaTh';
const combinedImageFolderId = '1HUvuvdM8Euvff-mBDmzmrd2j1TCkJHlW';

const stamp = {
  x: 360, y: 165, width: 85, height: 85
};

function generateCombinedImage() {
  // baseImageと同じサイズのスライドを作る
  const baseImage = Drive.Files.get(baseImageId);
  const { width, height } = baseImage.imageMediaMetadata;
  const presentationId = DocsServiceApp.createNewSlidesWithPageSize({
    title: 'temp slide',
    width: { unit: 'pixel', size: width },
    height: { unit: 'pixel', size: height }
  });
  const slide = SlidesApp.openById(presentationId);
  const page = slide.getSlides()[0];
  
  // 画像を挿入する
  const baseFile = DriveApp.getFileById(baseImageId);
  const baseBlob = baseFile.getBlob();
  page.insertImage(baseBlob);
  const stampBlob = DriveApp.getFileById(stampImageId).getBlob();
  page.insertImage(stampBlob, stamp.x, stamp.y, stamp.width, stamp.height);
  slide.saveAndClose();

  // サムネイル画像を生成する
  const thumbnail = Slides.Presentations.Pages.getThumbnail(presentationId, page.getObjectId(), {
    'thumbnailProperties.thumbnailSize': 'LARGE',
    'thumbnailProperties.mimeType': 'PNG'
  });
  const thumbnailUrl = thumbnail.contentUrl.replace(/=s\d+/, '=s' + width);
  const thumbnailBlob = UrlFetchApp.fetch(thumbnailUrl).getBlob().setName('combined_' + baseFile.getName());
  DriveApp.getFolderById(combinedImageFolderId).createFile(thumbnailBlob);
  DriveApp.getFileById(presentationId).setTrashed(true);
}
```

# GPT-4を使って作ったときの所感
何とか実現はできたもののかなり苦戦しました。予め準備するものが2つあります。

1. サイズを調整したスライド。合成結果の画像サイズをApps Scriptから制御できないので、テンプレートとスライドを準備して、それをコピーして使います。
1. 2つの関数の間で情報を連携するためのGoogleスプレッドシート。Apps Scriptからスライドを操作した結果を反映するには`SlidesApp.Presentation.saveAndClose()`を実行する必要がありますが、スライドの画像を取得するためにはそれだけでは足りず、一時的なスライドを作成したらApps Scriptを終了し、サムネイル画像を生成する関数を別途呼び出す必要がありました。時間をあけて2つの関数を実行するために使います。

コードを書くのにChatGPT-4を使いました。しかし思ったよりも多くの時間を使ってしまいました。Apps ScriptはAPIに統一感がなく、使いこなすための細かなノウハウが色々と必要です。こうした制約事項が多く存在する実行環境では、事前に十分な情報をChatGPTに提供することが重要です。ChatGPTへの指示について、試行錯誤した結果「最初にこう伝えるべきだった」とまとめた指示を以下に掲載します。制約事項としてかなり細かな点まで伝えています。ChatGPTは「分からない」といわずに、あたかも実行できそうなコードを提示してきます。実行してみないと正しいかどうかが分からないので、検証にとても時間がかかってしまいました。

## 生成されたコード

```javascript:Code.gs
// 事前に分かっている情報
var baseImageId = '...';
var stampImageId = '...';
var templatePresentationId = '...';
var combinedImageFolderId = '...';
var spreadsheetId = '...';
const x = 481;
const y = 215;
const width = 100;
const height = 100;

function createTemporarySlide() {
  // テンプレート用のGoogleスライドを複製
  var templatePresentationFile = DriveApp.getFileById(templatePresentationId);
  var temporarySlideFile = templatePresentationFile.makeCopy('Temporary Slide');
  var temporarySlideId = temporarySlideFile.getId();

  // 一時的なスライドにアクセス
  var temporarySlide = SlidesApp.openById(temporarySlideId);
  var slideWidth = temporarySlide.getPageWidth();
  var slideHeight = temporarySlide.getPageHeight();

  // 画像を追加
  var baseImage = Drive.Files.get(baseImageId);
  var stampImage = Drive.Files.get(stampImageId);
  var baseImageBlob = DriveApp.getFileById(baseImageId).getBlob();
  var stampImageBlob = DriveApp.getFileById(stampImageId).getBlob();

  // baseImageのサイズを取得
  var baseImageWidth = baseImage.imageMediaMetadata.width;
  var baseImageHeight = baseImage.imageMediaMetadata.height;

  // baseImageを挿入
  var ratio = Math.min(slideWidth / baseImageWidth, slideHeight / baseImageHeight);
  var insertedBaseImage =  temporarySlide.getSlides()[0].insertImage(baseImageBlob);
  insertedBaseImage.setWidth(baseImageWidth * ratio).setHeight(baseImageHeight * ratio);

  // stampImageをリサイズおよび位置を調整して挿入
  var resizedWidth = width * ratio;
  var resizedHeight = height * ratio;
  var newPositionX = x * ratio;
  var newPositionY = y * ratio;

  var insertedStampImage = temporarySlide.getSlides()[0].insertImage(stampImageBlob);
  insertedStampImage.setTop(newPositionY).setLeft(newPositionX).setWidth(resizedWidth).setHeight(resizedHeight);

  // スライドへの変更を保存して閉じる
  temporarySlide.saveAndClose();

  // 一時的なスライドの情報をスプレッドシートに追加
  var spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  var sheet = spreadsheet.getSheetByName('Temporary Slides');
  sheet.appendRow([temporarySlideId, 'Not generated']);
}

function generateCombinedImage() {
  // スプレッドシートから未生成の一時的なスライドを取得
  var spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  var sheet = spreadsheet.getSheetByName('Temporary Slides');
  var data = sheet.getDataRange().getValues();
  for (var i = 0; i < data.length; i++) {
    if (data[i][1] === 'Not generated') {
      var temporarySlideId = data[i][0];
      
      // サムネイル画像を生成
      var temporarySlideFile = DriveApp.getFileById(temporarySlideId);
      var thumbnailBlob = temporarySlideFile.getThumbnail().getAs('image/png');
      var combinedImage = DriveApp.createFile(thumbnailBlob);
      
      // 生成された画像を指定のフォルダに移動
      var combinedImageFolder = DriveApp.getFolderById(combinedImageFolderId);
      combinedImage.moveTo(combinedImageFolder);
      
      // スプレッドシートのステータスを更新
      sheet.getRange(i + 1, 2).setValue('Generated');
      
      // 一時的なスライドを削除
      temporarySlideFile.setTrashed(true);
    }
  }
}

// トリガーを設定してgenerateCombinedImage()を実行する（例：1分おき）
function createTimeDrivenTrigger() {
  ScriptApp.newTrigger('generateCombinedImage')
  .timeBased()
  .everyMinutes(1)
  .create();
}
```

## ChatGPT-4への指示

あなたは分かりやすいコードを書くことに定評のあるシニアプログラマーです。私がやりたいことと制限事項を伝えるので、それに従ってGoogle Apps Scriptのコードを書いてください。

### やりたいこと
- Google Apps Scriptを使って、ある画像baseImageに別の画像stampImageを重ね合わせて、新しい画像combinedImageを作る。
- baseImageとstampImageはGoogleドライブに保存してある。combinedImageもドライブに保存する。
- combinedImageのサイズを決めるために、テンプレート用のGoogleスライドtemplatePresentationが予め準備されている（ファイル > ページ設定、で希望するサイズに変更）。
- 事前に分かっていることは次の通り：
  - baseImage、stampImage、templatePresentationのそれぞれのファイルID。
  - stampImageを重ね合わせる位置である、x, y, width, height。
  - combinedImageを保存する先のフォルダーID。
  - 処理状況を記録するスプレッドシートのID。

### 実現するにあたっての制限事項
- テンプレート用のGoogleスライドを複製して、画像を合成するための一時的なスライドとして利用する。複製には`DriveApp.File.makeCopy()`を使い、生成したファイルのファイルIDから、`SlidesApp.openById()`を使ってスライドにアクセスする。
- スライドから画像を生成するのに `DriveApp.File.getThumbnail().getAs('image/png')`を使う。
- `getThumbnail()`メソッドで画像を生成するには、一旦、Apps Scriptの実行を終了させて、もう一度アクセスする必要があった。そこで、一時的なスライドの作成と、一時的なスライドからサムネイル画像を作成するfunctionを分ける。一時的なスライドを作成して、baseImageとstampImageを追加したあと、そのスライドのファイルIDと、サムネイル画像未生成であることをGoogleスプレッドシートに`appendRow()`で追記する。トリガーを使ってサムネイル画像を生成する際、そのスプレッドシートでサムネイル画像が未生成のファイルIDを対象とする。
- 挿入するbaseImageのサイズは、一時的なスライドのサイズと同じにする。スライドのサイズとbaseImageのサイズの比率を計算して、stampImageの位置とサイズに反映する。スライドのサイズは`SlidesApp.Presentation.getPageWidth()`と`SlidesApp.Presentation.getPageHeight()`で取得する。なお、getPageWidht()とgetPageHeight()にはgetMagnitude()もgetValue()も不要。
- スライドのサイズとbaseImageのサイズの比率を計算し、その比率をbaseImageに反映して挿入する。具体的には、`Math.min(slideWidth / baseImageWidth, slideHeight / baseImageHeight)`を使用してスライドの幅と高さの比率の小さい方を選択し、`insertedBaseImage.setWidth(baseImageWidth * ratio).setHeight(baseImageHeight * ratio)`を使ってbaseImageのサイズを調整する。
- baseImage, stampImageのサイズは、Advanced SerivceのDrive APIを使う。`Drive.Files.get(fileId).imageMediaMetadata`のwidthとheightで取得できる。
- Apps Scriptの操作をスライドに反映するために、`SlidesApp.Presentation.saveAndClose()`メソッドを呼び出す必要がある。

## 特にハマったポイント

最初は、テンプレートスライドを準備せず、まっさらなスライドを作ってbaseImageのサイズに変更しようと試していました。なのに、全く動きません...。

Apps ScriptからGoogle Workspaceアプリを操作するには、2つのAPIがあります。[Google ServicesとAdvanced Services](https://developers.google.com/apps-script/reference?hl=en
)です。スライドなら、前者が[SlidesApp](https://developers.google.com/apps-script/reference/slides/slides-app?hl=en)で、後者が[Slides API](https://developers.google.com/apps-script/advanced/slides?hl=en)です。

Slides APIの方が、より高度な処理を実行できます。スライドを生成する[createの仕様書](https://developers.google.com/slides/api/reference/rest/v1/presentations/create?hl=en)には、Request bodyに`pageSize`を指定できると書いてあります。

```json
{
  "presentationId": string,
  "pageSize": {
    object (Size)
  },
```

しかし、[Issue Tracker](https://issuetracker.google.com/issues/74602138)によると、説明に`pageSizeは反映しない`と書いてあるからバグじゃない、とのこと。

> If a presentationId is provided, it is used as the ID of the new presentation. Otherwise, a new ID is generated. Other fields in the request, including any provided content, are ignored.

> presentationId を指定すると、新しいプレゼンテーションの ID として使用されます。それ以外の場合は、新しい ID が生成されます。リクエストの他のフィールド（指定されたコンテンツを含む）は無視されます。

さすがに「なんじゃそりゃー」と思ってしまいました。私は、API仕様書で最初に引数を確認します。`create()`の引数に`pageSize`と書いてあったので、サイズを指定してスライドを作れるんだ、と思いこんでしまったのです。