Google Apps Scriptを使って、2つの画像を1つの画像に合成してみました。見積書に押印する(見積書画像に、押印画像を重ね合わせる)のに使うつもりです。
JavaScriptで画像を合成するにはcanvasが便利ですが、 ウェブブラウザー上でしか使えません。 今回はサーバー側で合成したかったので、GoogleスライドをApps Scriptで操作することで実現しました。一時的なスライドを作り、そこに2つの画像を挿入後、1つの画像として出力します。
2023/04/02 追記:
後日、Google Apps Script側でcanvasを含むHTMLを作成し、それをPDFとして保存すれば、2つの画像を合成できることが分かりました。
2023/04/08 追記:
後日、DocsServiceAppを使うことで、サイズを指定したGoogleスライドを作成できることが分かりました。pptxファイルを作って、変換しているようです。またSlides.Presentations.Pages.getThumbnailを使えば、Apps Scriptの実行を終了させることなくサムネイル画像を生成できます。
DocsServiceAppによる実現方法
Google Apps Scriptを使って、ある画像baseImageに別の画像stampImageを重ね合わせて、新しい画像combinedImageを作ります。baseImageとstampImageはGoogleドライブに保存されています。
事前準備
サービスにDrive API
とGoogle Slides API
を追加します。
ライブラリーにDocsServiceAppを追加します。Script IDは
108j6x_ZX544wEhGkgddFYM6Ie09edDqXaFwnW3RVFQCLHw_mEueqUHTW
です。
実装
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つあります。
- サイズを調整したスライド。合成結果の画像サイズをApps Scriptから制御できないので、テンプレートとスライドを準備して、それをコピーして使います。
- 2つの関数の間で情報を連携するためのGoogleスプレッドシート。Apps Scriptからスライドを操作した結果を反映するには
SlidesApp.Presentation.saveAndClose()
を実行する必要がありますが、スライドの画像を取得するためにはそれだけでは足りず、一時的なスライドを作成したらApps Scriptを終了し、サムネイル画像を生成する関数を別途呼び出す必要がありました。時間をあけて2つの関数を実行するために使います。
コードを書くのにChatGPT-4を使いました。しかし思ったよりも多くの時間を使ってしまいました。Apps ScriptはAPIに統一感がなく、使いこなすための細かなノウハウが色々と必要です。こうした制約事項が多く存在する実行環境では、事前に十分な情報をChatGPTに提供することが重要です。ChatGPTへの指示について、試行錯誤した結果「最初にこう伝えるべきだった」とまとめた指示を以下に掲載します。制約事項としてかなり細かな点まで伝えています。ChatGPTは「分からない」といわずに、あたかも実行できそうなコードを提示してきます。実行してみないと正しいかどうかが分からないので、検証にとても時間がかかってしまいました。
生成されたコード
// 事前に分かっている情報
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です。スライドなら、前者がSlidesAppで、後者がSlides APIです。
Slides APIの方が、より高度な処理を実行できます。スライドを生成するcreateの仕様書には、Request bodyにpageSize
を指定できると書いてあります。
{
"presentationId": string,
"pageSize": {
object (Size)
},
しかし、Issue Trackerによると、説明に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
と書いてあったので、サイズを指定してスライドを作れるんだ、と思いこんでしまったのです。