見出し画像

【セミナー資料自動化】AIが資料からレジュメを自動作成。もうパワポ作業に悩まない、【GAS活用術】


はじめに

「セミナー開催は決まったが、あのレジュメ作りが本当に憂鬱だ…」
「解説資料の要点を盛り込みたいのに、パワポだと見づらくなる…」
「巷のAIスライドツールは、プレゼン用で情報量が少なめ。配布資料には向かない…」

このような悩みを一度は抱えたことがあるのではないでしょうか?

登壇するセミナーによっては1スライド1メッセージのようなプレゼン資料ではなく、参加者の満足度を左右する「分かりやすく、かつ情報量が豊富なレジュメ」が不可欠です。しかし、その作成には膨大な時間と手間がかかるのが現実….

AIに助けてほしいのは、キーワードが並んだプレゼン資料ではなく、専門家としての信頼性を示し、参加者が後から何度も読み返せるような、“読む”ための配付資料(レジュメ)ではないでしょうか?

まずは、今回ご紹介するツールで、どのようなレジュメが生成されるかをご覧ください。

元資料:消費税法 (基 礎 編) 令和7年度版

https://www.nta.go.jp/about/organization/ntc/kohon/syouhi/pdf/all.pdf

資料のPDFを添付し、GemにJSONを出力してもらう…
JSONをWebアプリにコピーすれば…
スライドが生成されます!
一気に30枚ものレジュメを生成することが可能!!
内容もしっかりしているので、図などを追加すればすぐに使えます。

▲AIが解説資料のPDFを読み込み、わずか数分で自動生成したレジュメの一例

このクオリティのレジュメが、AIにお手元の解説資料、論文、先生ご自身が書きためた原稿などを読み込ませるだけで、わずか数分で完成します。

他のAIスライド生成ツールとの決定的な違いは、その圧倒的な情報量です。メインとなる解説文をしっかりと記述し、サイドコラムに関連情報や補足情報を併記するといった、まさに“配布資料”としての役割を十分に果たせるレジュメ作成に特化しています。

この記事では、この強力な「AIレジュメ自動生成ツール」をGoogleWorkspace環境に導入し、すぐに使えるようになるまでの全手順と、コピー&ペーストでそのまま使える全てのコードを、ITが苦手な方でも絶対に挫折しないよう、丁寧に解説します。

煩雑な資料作成業務はAIという名の優秀なアシスタントに任せ、人間にしかできない付加価値の高い仕事に集中する。そんな新しい働き方を、今日から始めてみませんか?


実際に導入していきましょう!

1. このツールが業務をどう変えるか

ここから、レジュメ作成業務を根底から覆す、具体的な手法について解説していきます。

私たちがこれから作るのは、AIが生成した「レジュメの設計図(JSON)」を読み込み、Googleスライドを自動で作成するウェブアプリケーションです。

よくある「AIスライド生成」が“話す”ためのプレゼン資料を目的とするのに対し、このツールは“読む”ための配付資料(レジュメ)を作ることを得意としています。

主な機能:

  • 長文・難解な文章に対応: AIが論文や解説記事などの複雑なテキストを読み解き、レジュメの各ページに要約・再構成します。

  • 情報量を担保できるレイアウト: 1ページあたりの文字量を多く確保できるレイアウトを用意。単なる箇条書きではない、解説文をしっかり載せられるのが強みです。

  • デザインの統一感を自動化: ご自身の事務所のテーマカラーなどを設定すれば、常に統一感のあるプロフェッショナルな見た目の資料が完成します。

  • 追記・修正も簡単: 既存のレジュメに、新しい法改正の情報をスライドとして追加していく、といった使い方も可能です。

このツールを使えば、今まで数時間、あるいは数日かかっていたレジュメ作成が、わずか数十分で完了するようになります。

2. レジュメ自動生成までの3ステップ

全体の流れは非常にシンプルです。

  • ステップ1:AIに元となる資料を読み込ませる

    • レジュメ化したい解説記事、参考資料のPDF、ご自身で作成したWordファイルなどをAI(ChatGPTのGPTsやGeminiなど)にインプットします。

    • Googleドライブ上のドキュメントをGemに読み込ませるのが最もスムーズです。

  • ステップ2:専用プロンプトで「設計図(JSON)」を出力させる

    • 次に、AIに対してプロンプトで資料を出力書式通りに出力してと依頼します。この時に情報量やレイアウトなどを具体的に定義するようにしているため、レジュメにする文章として最適な形で出力されます。

  • ステップ3:本ツールに「設計図」を貼り付けてレジュメを生成!

    • AIが出力したJSONデータをコピーし、私たちがこれから作成する「レジュメ自動生成ツール」の画面に貼り付け、「生成」ボタンをクリックします。

    • 数秒〜数分で、Googleスライド形式でレジュメが完成します。

3. さらに使いこなすために:AIへの指示のコツ

専用プロンプトだけでも十分に高品質なレジュメが生成できますが、AIへの指示にもう一工夫加えることで、さらに先生の意図を反映した、精度の高い成果物を得ることができます。

Tips1:専用プロンプトを登録したカスタムAI(Gem)を用意する

毎回長いプロンプトをコピー&ペーストするのは手間です。Geminiの「Gems」のような機能を使えば、今回ご紹介した専用プロンプトをあらかじめ記憶させた、「レジュメ作成専門のAIアシスタント」を作ることができます。
これにより、次回からは資料を渡すだけで、即座にJSONの生成を開始できます。

GemはGoogleドライブのサイドバーでも扱えるようになっていますが、まだ出力文字数が少ないため、Geminiの方から処理することをお勧めします。

Tips2:生成してほしいレジュメの「要件」を具体的に伝える
資料をAIに渡す際に、以下のような「要件」を付け加えることで、生成されるレジュメの構成や内容をコントロールしやすくなります。

  • 全体のボリューム: 「この資料から、全部で15枚程度のスライドにまとめてください」

  • 対象読者: 「法律の初学者向けなので、専門用語は避け、平易な言葉で説明してください」

  • 話の構成: 「序盤で問題提起、中盤で具体的な解説、終盤でまとめ、という構成にしてください」

このように、AIを単なる「要約ツール」としてではなく、「構成作家」として扱うことで、より質の高いレジュメを効率的に作成することが可能になります。

4. 「レジュメ自動生成ツール」の導入方法と全コード

それでは、主役となるツールの導入方法を解説します。このセクションは特に丁寧に解説しますので、一つずつ画面をイメージしながら進めてください。 Googleアカウントさえあれば、追加費用なしで構築できます。

ステップ1:Google Apps Scriptプロジェクトの準備

  1. Google Apps Scriptを開く: まず、Google Apps Script にアクセスし、「新しいプロジェクト」を開始します。

  2. ファイルを作成する: プロジェクトが開いたら、3つのファイルを用意します。

    • コード.gs (最初からあります)

    • layouts.gs (「ファイル」の横の + を押し、「スクリプト」を選択して作成)

    • index.html (「ファイル」の横の + を押し、「HTML」を選択して作成)

  3. コードを貼り付ける: 後述するコードを、それぞれのファイルに正確にコピー&ペーストしてください。

  4. サービスにSlidesを追加する:


GoogleSlidesAPIを追加するのを忘れずに!
最終系はこんな感じ

ステップ2:ウェブアプリとしてデプロイ(初回)

コードを貼り付けたら、ツールをインターネット上で利用できるように「デプロイ」という作業を行います。

  1. デプロイ画面を開く: スクリプトエディタ画面の右上にある青い「デプロイ」ボタンを押し、ドロップダウンメニューから「新しいデプロイ」を選択します。

  2. デプロイ設定を行う:

    • 「種類の選択」の横にある歯車アイコン⚙️をクリックし、「ウェブアプリ」を選択します。

    • 説明: (任意ですが「セミナーレジュメ自動生成ツール」など分かりやすい名前を推奨します)

    • 次のユーザーとして実行: 「自分」(先生ご自身のGoogleアカウント)を選択します。

    • アクセスできるユーザー: ここが重要です。

      • 「自分のみ」: 最も安全な設定です。まずはこれでテストすることをおすすめします。

      • 「○○内の全員」:組織内で共有する場合はこの設定を選びます。

      • これら以外の設定は原則として推奨しません。

      • 設定が完了したら、「デプロイ」ボタンをクリックします。

ステップ3:アクセス承認(初回のみ)

デプロイボタンを押すと、このスクリプトがGoogleスライドなどを操作して良いか、許可を求める画面が表示されます。

  1. 承認を開始: 「アクセスを承認」ボタンが表示されるので、クリックします。

  2. アカウントを選択: Googleアカウントの選択画面が表示されたら、ご自身のアカウントをクリックします。

  3. 警告画面の対応: 「このアプリは Google で確認されていません」という警告画面が表示されます。これはご自身で作成したプログラムの場合、必ず表示されるものですのでご安心ください。

    • 「詳細」というリンクをクリックします。

    • 画面の一番下に表示される「(プロジェクト名)に移動(安全ではありません)」というリンクをクリックします。

  4. 最終許可: スクリプトが要求する権限(Googleスライドの作成や編集など)の一覧が表示されます。内容を確認し、右下の「許可」ボタンをクリックします。

ステップ4:ツールのURLを取得

承認が完了すると、「デプロイが成功しました」という画面が表示され、ウェブアプリのURLが発行されます。

  • このURLをコピーして、ブックマークなどに保存してください。このURLが、先生専用の「レジュメ自動生成ツール」の入り口となります。

コピペ用

コード.gs

// ===============================================================
// ウェブアプリのメイン処理
// ===============================================================

/**
 * ウェブアプリのURLにアクセスされた際にHTMLページを表示する
 */
function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('AI-Powered Slide Generator')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.DEFAULT);
}

/**
 * フロントエンドからのリクエストを処理するエントリーポイント
 */
function processRequest(request) {
  try {
    const jsonData = JSON.parse(request.jsonText);
    const styles = buildStyles(request.colors);
    
    let presentation;
    if (request.generationMode === 'existing' && request.presentationId) {
      presentation = SlidesApp.openById(request.presentationId);
    } else {
      presentation = createNewPresentation(jsonData.title);
    }
    
    generateSlidesFromJSON(presentation, jsonData, styles, request.footerText);
    
    return presentation.getUrl();

  } catch (e) {
    Logger.log(`Error in processRequest: ${e.stack}`);
    throw new Error(`サーバー側でエラーが発生しました: ${e.message}`);
  }
}

// ===============================================================
// スライドデザインの定義
// ===============================================================

const LAYOUT_STYLES = {
  slide: { sidePadding: 42.5, mainColumnGap: 30, lineSpacing: 1.2 },
  header: { top: 10, height: 15, underlineTop: 32 },
  mainTitle: { rectTop: 45, rectWidth: 2, rectHeight: 35, textTop: 45, height: 35, leftMargin: 10 },
  subtitle: { top: 85, height: 20 },
  contentArea: { top: 115, height: 262 },
  column: { titleTopMargin: 0, titleHeight: 20, titleUnderlineTopMargin: 25, bodyTopMargin: 35 },
  mainAndColumn: { padding: 15, titleTopMargin: 5, titleHeight: 20, titleUnderlineTopMargin: 28, body1TopMargin: 40, body1Height: 70, title2TopMargin: 125, title2UnderlineTopMargin: 148, body2TopMargin: 160, body2Height: 50 },
  footer: { top: 382.7, height: 20, size: 9, color: '#888888' }
};

/**
 * HTMLから受け取った色設定を基に、スライド全体のスタイルオブジェクトを構築する
 */
function buildStyles(colors) {
    return {
        fontFamily: 'Noto Sans JP',
        slideBGC: '#FFFFFF',
        colors: { main: colors.primary, secondary: colors.secondary, accent: colors.accent, subtext: '#999999', body: '#333333' },
        chapterTitle: { size: 8, color: colors.secondary, borderColor: colors.secondary, borderWeight: 0.5 },
        h2: { size: 17, color: colors.primary },
        h3: { size: 12, color: colors.primary, borderColor: colors.primary, borderWeight: 1 },
        slideSubtitle: { size: 10, color: colors.accent },
        body: { size: 10, color: '#333333' },
        caption: { size: 10, color: '#333333' },
        layout: LAYOUT_STYLES,
        sideColumn: { bg: '#f9f9f9' }
    };
}


// ===============================================================
// スライド生成のメインロジック
// ===============================================================

/**
 * JSONデータに基づいてスライドを一枚ずつ生成する
 */
function generateSlidesFromJSON(presentation, jsonData, styles, footerText) {
  const initialSlideCount = presentation.getSlides().length;

  jsonData.slides.forEach(slideData => {
    const slide = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
    slide.getBackground().setSolidFill(styles.slideBGC);
    const pageWidth = presentation.getPageWidth();
    
    const sidePadding = styles.layout.slide.sidePadding;
    const contentWidth = pageWidth - (sidePadding * 2);

    // ヘッダーとタイトルの描画
    const header = styles.layout.header;
    const chapterBox = slide.insertTextBox(slideData.chapter, sidePadding, header.top, 300, header.height);
    applyTextStyle(chapterBox, styles.fontFamily, styles.chapterTitle.size, styles.chapterTitle.color, true, styles.layout.slide.lineSpacing);
    const chapterLine = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, sidePadding, header.underlineTop, sidePadding + contentWidth, header.underlineTop);
    chapterLine.getLineFill().setSolidFill(styles.chapterTitle.borderColor);
    chapterLine.setWeight(styles.chapterTitle.borderWeight);
    
    const mainTitle = styles.layout.mainTitle;
    const titleShape = slide.insertShape(SlidesApp.ShapeType.RECTANGLE, sidePadding, mainTitle.rectTop, mainTitle.rectWidth, mainTitle.rectHeight);
    titleShape.getFill().setSolidFill(styles.h2.color);
    titleShape.getBorder().setTransparent();
    
    const titleX = sidePadding + mainTitle.rectWidth + mainTitle.leftMargin;
    const titleWidth = pageWidth - titleX - sidePadding;
    const titleBox = slide.insertTextBox(slideData.title, titleX, mainTitle.textTop, titleWidth, mainTitle.height);
    applyTextStyle(titleBox, styles.fontFamily, styles.h2.size, styles.h2.color, true, styles.layout.slide.lineSpacing);
    
    const subtitle = styles.layout.subtitle;
    const subtitleBox = slide.insertTextBox(slideData.subtitle, sidePadding, subtitle.top, contentWidth, subtitle.height);
    applyTextStyle(subtitleBox, styles.fontFamily, styles.slideSubtitle.size, styles.slideSubtitle.color, false, styles.layout.slide.lineSpacing);

    // レイアウトごとの設定計算
    const contentArea = styles.layout.contentArea;
    const layoutConfig = {
      contentAreaTop: contentArea.top, contentAreaHeight: contentArea.height, 
      gap: styles.layout.slide.mainColumnGap,
      sidePadding: sidePadding, pageWidth: pageWidth
    };
    layoutConfig.sideColumnWidth = contentWidth * 0.35;
    layoutConfig.sideColumnLeft = pageWidth - sidePadding - layoutConfig.sideColumnWidth;
    layoutConfig.mainContentWidth = contentWidth - layoutConfig.sideColumnWidth - layoutConfig.gap;

    // レイアウトに応じて描画関数を呼び出し
    switch (slideData.layout) {
      case "oneColumn": layoutOneColumn(slide, styles, slideData.content, layoutConfig); break;
      case "twoColumn": layoutTwoColumn(slide, styles, slideData.content, layoutConfig); break;
      case "threeColumn": layoutThreeColumn(slide, styles, slideData.content, layoutConfig); break;
      case "mainAndColumn": layoutMainAndColumn(slide, styles, slideData.content, layoutConfig); break;
      default: Logger.log(`不明なレイアウトが指定されました: ${slideData.layout}`); break;
    }
  });
  
  // フッターテキストが入力されている場合のみ、フッターを追加
  if (footerText) {
    addFooterToAllSlides(presentation, footerText, styles, initialSlideCount);
  }

  Logger.log(`JSONからのスライド生成・追加が完了しました: ${presentation.getUrl()}`);
}


// ===============================================================
// ヘルパー関数群
// ===============================================================

/**
 * 新しいプレゼンテーションを作成する
 */
function createNewPresentation(title) {
  return SlidesApp.create(title);
}

/**
 * フッターをスライドに追加する
 */
function addFooterToAllSlides(presentation, footerText, styles, initialSlideCount) {
  const allSlides = presentation.getSlides();
  const footerStyle = styles.layout.footer;
  const sidePadding = styles.layout.slide.sidePadding;
  
  for (let i = initialSlideCount; i < allSlides.length; i++) {
    const slide = allSlides[i];
    const footerTextBox = slide.insertTextBox(footerText, sidePadding, footerStyle.top, 300, footerStyle.height);
    const style = footerTextBox.getText().getTextStyle();
    style.setFontFamily(styles.fontFamily).setFontSize(footerStyle.size).setForegroundColor(footerStyle.color);
  }
}


/**
 * テキストボックスにスタイルを適用し、Markdownの太字を処理する
 */
function applyTextStyle(textBox, fontFamily, size, color, isBold = false, lineSpacing = 1.0) {
  const text = textBox.getText();
  const baseStyle = text.getTextStyle();
  baseStyle.setFontFamily(fontFamily);
  baseStyle.setFontSize(size);
  baseStyle.setForegroundColor(color);
  baseStyle.setBold(isBold);
  
  text.getParagraphs().forEach(para => {
    para.getRange().getParagraphStyle().setLineSpacing(lineSpacing * 100);
  });

  let plainText = text.asString();
  const boldRanges = [];
  const boldPattern = /\*\*(.*?)\*\*/g;
  let match;
  let newText = '';
  let lastIndex = 0;

  while ((match = boldPattern.exec(plainText)) !== null) {
    const boldContent = match[1];
    newText += plainText.substring(lastIndex, match.index);
    const boldStartIndex = newText.length;
    newText += boldContent;
    boldRanges.push({ start: boldStartIndex, end: newText.length });
    lastIndex = boldPattern.lastIndex;
  }
  newText += plainText.substring(lastIndex);
  
  text.setText(newText);
  
  boldRanges.forEach(range => {
    text.getRange(range.start, range.end).getTextStyle().setBold(true);
  });
}

layout.gs

// ===============================================================
// レイアウト定義用ファイル
// ===============================================================

/**
 * 「メイン+コラム」のレイアウトを生成する
 */
function layoutMainAndColumn(slide, styles, content, config) {
  // --- サイドコラムの描画 ---
  const localStyleCol = styles.layout.mainAndColumn;
  const colLeft = config.sideColumnLeft + localStyleCol.padding;
  const colTop = config.contentAreaTop;
  const colWidth = config.sideColumnWidth - (localStyleCol.padding * 2);
  const lineSpacing = styles.layout.slide.lineSpacing;

  const columnBg = slide.insertShape(SlidesApp.ShapeType.RECTANGLE, config.sideColumnLeft, colTop, config.sideColumnWidth, config.contentAreaHeight);
  columnBg.getFill().setSolidFill(styles.sideColumn.bg);
  columnBg.getBorder().setTransparent();

  const colTitle1 = slide.insertTextBox(content.column.title1, colLeft, colTop + localStyleCol.titleTopMargin, colWidth, localStyleCol.titleHeight);
  applyTextStyle(colTitle1, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  const colTitleLine1 = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, colLeft, colTop + localStyleCol.titleUnderlineTopMargin, colLeft + colWidth, colTop + localStyleCol.titleUnderlineTopMargin);
  colTitleLine1.getLineFill().setSolidFill(styles.h3.borderColor);
  colTitleLine1.setWeight(styles.h3.borderWeight);
  const colBody1 = slide.insertTextBox(content.column.body1, colLeft, colTop + localStyleCol.body1TopMargin, colWidth, localStyleCol.body1Height);
  applyTextStyle(colBody1, styles.fontFamily, styles.caption.size, styles.colors.body, false, lineSpacing);

  const colTitle2 = slide.insertTextBox(content.column.title2, colLeft, colTop + localStyleCol.title2TopMargin, colWidth, localStyleCol.titleHeight);
  applyTextStyle(colTitle2, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  const colTitleLine2 = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, colLeft, colTop + localStyleCol.title2UnderlineTopMargin, colLeft + colWidth, colTop + localStyleCol.title2UnderlineTopMargin);
  colTitleLine2.getLineFill().setSolidFill(styles.h3.borderColor);
  colTitleLine2.setWeight(styles.h3.borderWeight);
  const colBody2 = slide.insertTextBox(content.column.body2, colLeft, colTop + localStyleCol.body2TopMargin, colWidth, localStyleCol.body2Height);
  applyTextStyle(colBody2, styles.fontFamily, styles.caption.size, styles.colors.body, false, lineSpacing);

  // --- メインコンテンツエリアの描画 ---
  const localStyleMain = styles.layout.column;
  const mainContentLeft = config.sidePadding;
  const mainContentTop = config.contentAreaTop;
  const mainContentWidth = config.mainContentWidth;
  
  const mainTitle = slide.insertTextBox(content.main.title, mainContentLeft, mainContentTop + localStyleMain.titleTopMargin, mainContentWidth, localStyleMain.titleHeight);
  applyTextStyle(mainTitle, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  
  const mainLine = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, mainContentLeft, mainContentTop + localStyleMain.titleUnderlineTopMargin, mainContentLeft + mainContentWidth, mainContentTop + localStyleMain.titleUnderlineTopMargin);
  mainLine.getLineFill().setSolidFill(styles.h3.borderColor);
  mainLine.setWeight(styles.h3.borderWeight);
  
  const mainBodyTop = mainContentTop + localStyleMain.bodyTopMargin;
  const mainBodyHeight = config.contentAreaHeight - localStyleMain.bodyTopMargin;
  const mainBody = slide.insertTextBox(content.main.body, mainContentLeft, mainBodyTop, mainContentWidth, mainBodyHeight);
  applyTextStyle(mainBody, styles.fontFamily, styles.body.size, styles.colors.body, false, lineSpacing);
}


/**
 * 「2カラム」のレイアウトを生成する
 */
function layoutTwoColumn(slide, styles, content, config) {
  const localStyle = styles.layout.column;
  const contentTop = config.contentAreaTop;
  const totalWidth = config.pageWidth - (config.sidePadding * 2);
  const columnWidth = (totalWidth - config.gap) / 2;
  const leftColumnLeft = config.sidePadding;
  const rightColumnLeft = config.sidePadding + columnWidth + config.gap;
  const bodyHeight = config.contentAreaHeight - localStyle.bodyTopMargin;
  const lineSpacing = styles.layout.slide.lineSpacing;

  // --- 左カラム ---
  const leftTitle = slide.insertTextBox(content.left.title, leftColumnLeft, contentTop + localStyle.titleTopMargin, columnWidth, localStyle.titleHeight);
  applyTextStyle(leftTitle, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  const leftLine = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, leftColumnLeft, contentTop + localStyle.titleUnderlineTopMargin, leftColumnLeft + columnWidth, contentTop + localStyle.titleUnderlineTopMargin);
  leftLine.getLineFill().setSolidFill(styles.h3.borderColor);
  leftLine.setWeight(styles.h3.borderWeight);
  const leftBody = slide.insertTextBox(content.left.body, leftColumnLeft, contentTop + localStyle.bodyTopMargin, columnWidth, bodyHeight);
  applyTextStyle(leftBody, styles.fontFamily, styles.body.size, styles.colors.body, false, lineSpacing);

  // --- 右カラム ---
  const rightTitle = slide.insertTextBox(content.right.title, rightColumnLeft, contentTop + localStyle.titleTopMargin, columnWidth, localStyle.titleHeight);
  applyTextStyle(rightTitle, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  const rightLine = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, rightColumnLeft, contentTop + localStyle.titleUnderlineTopMargin, rightColumnLeft + columnWidth, contentTop + localStyle.titleUnderlineTopMargin);
  rightLine.getLineFill().setSolidFill(styles.h3.borderColor);
  rightLine.setWeight(styles.h3.borderWeight);
  const rightBody = slide.insertTextBox(content.right.body, rightColumnLeft, contentTop + localStyle.bodyTopMargin, columnWidth, bodyHeight);
  applyTextStyle(rightBody, styles.fontFamily, styles.body.size, styles.colors.body, false, lineSpacing);
}


/**
 * 「3カラム」のレイアウトを生成する
 */
function layoutThreeColumn(slide, styles, content, config) {
  const localStyle = styles.layout.column;
  const contentTop = config.contentAreaTop;
  const totalWidth = config.pageWidth - (config.sidePadding * 2);
  const columnWidth = (totalWidth - (config.gap * 2)) / 3;
  const lineSpacing = styles.layout.slide.lineSpacing;

  for (let i = 0; i < content.length; i++) {
    const columnData = content[i];
    const columnLeft = config.sidePadding + (columnWidth + config.gap) * i;

    const title = slide.insertTextBox(columnData.title, columnLeft, contentTop + localStyle.titleTopMargin, columnWidth, localStyle.titleHeight);
    applyTextStyle(title, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
    const line = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, columnLeft, contentTop + localStyle.titleUnderlineTopMargin, columnLeft + columnWidth, contentTop + localStyle.titleUnderlineTopMargin);
    line.getLineFill().setSolidFill(styles.h3.borderColor);
    line.setWeight(styles.h3.borderWeight);
    
    const bodyTop = contentTop + localStyle.bodyTopMargin;
    const bodyHeight = config.contentAreaHeight - localStyle.bodyTopMargin;
    const body = slide.insertTextBox(columnData.body, columnLeft, bodyTop, columnWidth, bodyHeight);
    applyTextStyle(body, styles.fontFamily, styles.body.size, styles.colors.body, false, lineSpacing);
  }
}


/**
 * 「1カラム」のレイアウトを生成する
 */
function layoutOneColumn(slide, styles, content, config) {
  const localStyle = styles.layout.column;
  const contentTop = config.contentAreaTop;
  const columnLeft = config.sidePadding;
  const columnWidth = config.pageWidth - (config.sidePadding * 2);
  const lineSpacing = styles.layout.slide.lineSpacing;

  const title = slide.insertTextBox(content.title, columnLeft, contentTop + localStyle.titleTopMargin, columnWidth, localStyle.titleHeight);
  applyTextStyle(title, styles.fontFamily, styles.h3.size, styles.h3.color, true, lineSpacing);
  const line = slide.insertLine(SlidesApp.LineCategory.STRAIGHT, columnLeft, contentTop + localStyle.titleUnderlineTopMargin, columnLeft + columnWidth, contentTop + localStyle.titleUnderlineTopMargin);
  line.getLineFill().setSolidFill(styles.h3.borderColor);
  line.setWeight(styles.h3.borderWeight);

  const bodyHeight = config.contentAreaHeight - localStyle.bodyTopMargin;
  const body = slide.insertTextBox(content.body, columnLeft, contentTop + localStyle.bodyTopMargin, columnWidth, bodyHeight);
  applyTextStyle(body, styles.fontFamily, styles.body.size, styles.colors.body, false, lineSpacing);
}

index.html

<!DOCTYPE html>
<html>
<head>
  <title>AI-Powered Slide Generator</title>
  <script src="/https://cdn.tailwindcss.com"></script>
  <style>
    /* Custom styles for the application */
    body {
      font-family: 'Inter', sans-serif;
    }
    .status-panel {
      min-height: 100px;
      transition: all 0.3s ease-in-out;
      white-space: pre-wrap; /* Allow line breaks in error messages */
      word-break: break-word;
    }
    .monospace-font {
        font-family: 'Courier New', Courier, monospace;
    }
    input[type="color"] {
        -webkit-appearance: none;
        border: none;
        width: 40px;
        height: 40px;
        border-radius: 50%;
        cursor: pointer;
    }
    input[type="color"]::-webkit-color-swatch-wrapper {
        padding: 0;
        border-radius: 50%;
    }
    input[type="color"]::-webkit-color-swatch {
        border: 1px solid #ccc;
        border-radius: 50%;
    }
    .hidden {
      display: none;
    }
  </style>
  <link rel="stylesheet" href="/https://rsms.me/inter/inter.css">
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen py-12">

  <div class="w-full max-w-3xl mx-auto p-8 bg-white rounded-xl shadow-lg">
    
    <div class="mb-6 text-center">
      <h1 class="text-3xl font-bold text-gray-800">AI-Powered Slide Generator</h1>
      <p class="text-gray-500 mt-2">AIが生成したJSONデータを貼り付けて、プレゼンテーションを自動作成します。</p>
    </div>

    <div>
      <label for="jsonInput" class="block text-sm font-medium text-gray-700 mb-2">JSONデータ:</label>
      <textarea id="jsonInput" rows="15" class="monospace-font p-3 block w-full text-sm text-gray-800 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500" placeholder="ここにAIが生成したJSONデータを貼り付けてください..."></textarea>
    </div>

    <div class="mt-6">
        <h3 class="text-lg font-medium text-gray-800 mb-3">カラー設定</h3>
        <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
            <div class="flex items-center space-x-3">
                <input type="color" id="primaryColor" value="#0D47A1">
                <label for="primaryColor" class="text-sm font-medium text-gray-700">プライマリ (タイトル/H3)</label>
            </div>
            <div class="flex items-center space-x-3">
                <input type="color" id="secondaryColor" value="#1565C0">
                <label for="secondaryColor" class="text-sm font-medium text-gray-700">セカンダリ (章タイトル)</label>
            </div>
            <div class="flex items-center space-x-3">
                <input type="color" id="accentColor" value="#42A5F5">
                <label for="accentColor" class="text-sm font-medium text-gray-700">アクセント (サブタイトル)</label>
            </div>
        </div>
    </div>
    
    <div class="mt-6 p-4 bg-gray-50 rounded-lg border">
        <h3 class="text-lg font-medium text-gray-800 mb-3">生成オプション</h3>
        <div class="flex flex-col space-y-3">
            <div class="flex items-center">
                <input type="radio" id="modeNew" name="generationMode" value="new" class="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500" checked>
                <label for="modeNew" class="ml-3 block text-sm font-medium text-gray-700">新しいプレゼンテーションを作成</label>
            </div>
            <div class="flex items-center">
                <input type="radio" id="modeExisting" name="generationMode" value="existing" class="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500">
                <label for="modeExisting" class="ml-3 block text-sm font-medium text-gray-700">既存のプレゼンテーションに追加</label>
            </div>
        </div>
        <div id="presentationIdContainer" class="mt-4 hidden">
            <label for="presentationIdInput" class="block text-sm font-medium text-gray-700">GoogleスライドID:</label>
            <input type="text" id="presentationIdInput" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" placeholder="URLまたはIDを貼り付け">
             <p class="mt-1 text-xs text-gray-500">例: .../presentation/d/ここにIDがあります/edit</p>
        </div>
    </div>
    
    <div class="mt-6 p-4 bg-gray-50 rounded-lg border">
        <h3 class="text-lg font-medium text-gray-800 mb-3">フッター設定</h3>
        <div>
            <label for="footerText" class="block text-sm font-medium text-gray-700">フッターに表示するテキスト (任意):</label>
            <input type="text" id="footerText" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" placeholder="例: 株式会社〇〇">
        </div>
    </div>


    <div class="mt-6 text-center">
      <button id="generateBtn" class="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-lg transition duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
        スライドを生成
      </button>
    </div>

    <div id="statusPanel" class="status-panel mt-6 p-4 bg-gray-50 rounded-lg border border-dashed border-gray-300 text-center">
      <p class="text-gray-500">生成ステータスがここに表示されます。</p>
    </div>

  </div>

  <script>
    // DOM要素の取得
    const jsonInput = document.getElementById('jsonInput');
    const generateBtn = document.getElementById('generateBtn');
    const statusPanel = document.getElementById('statusPanel');
    const primaryColorInput = document.getElementById('primaryColor');
    const secondaryColorInput = document.getElementById('secondaryColor');
    const accentColorInput = document.getElementById('accentColor');
    const modeNewRadio = document.getElementById('modeNew');
    const modeExistingRadio = document.getElementById('modeExisting');
    const presentationIdContainer = document.getElementById('presentationIdContainer');
    const presentationIdInput = document.getElementById('presentationIdInput');
    const footerTextInput = document.getElementById('footerText');

    // イベントリスナーの設定
    generateBtn.addEventListener('click', handleGenerateClick);
    modeNewRadio.addEventListener('change', togglePresentationIdInput);
    modeExistingRadio.addEventListener('change', togglePresentationIdInput);
    
    function togglePresentationIdInput() {
        if (modeExistingRadio.checked) {
            presentationIdContainer.classList.remove('hidden');
        } else {
            presentationIdContainer.classList.add('hidden');
        }
    }
    
    function handleGenerateClick() {
      const jsonText = jsonInput.value.trim();
      
      if (!jsonText) {
        updateStatus('error', 'JSONデータが入力されていません。');
        return;
      }
      
      const generationMode = document.querySelector('input[name="generationMode"]:checked').value;
      let presentationId = null;

      if (generationMode === 'existing') {
          presentationId = presentationIdInput.value.trim();
          const match = presentationId.match(/presentation\/d\/([a-zA-Z0-9_-]+)/);
          if (match && match[1]) {
              presentationId = match[1];
              presentationIdInput.value = presentationId;
          }
          if (!presentationId) {
              updateStatus('error', '既存のスライドに追加するには、GoogleスライドIDを入力してください。');
              return;
          }
      }
      
      toggleButtonState(true, '生成中...');
      updateStatus('loading', 'サーバーと通信し、スライドを生成しています...\n(処理には数分かかる場合があります)');

      const request = {
        jsonText: jsonText,
        colors: {
          primary: primaryColorInput.value,
          secondary: secondaryColorInput.value,
          accent: accentColorInput.value,
        },
        generationMode: generationMode,
        presentationId: presentationId,
        footerText: footerTextInput.value.trim()
      };
      
      google.script.run
        .withSuccessHandler(onGenerationSuccess)
        .withFailureHandler(onGenerationFailure)
        .processRequest(request);
    }

    function onGenerationSuccess(presentationUrl) {
      const successMessage = `
        <p class="text-green-700 font-semibold">プレゼンテーションの生成・追加が完了しました!</p>
        <a href="${presentationUrl}" target="_blank" class="inline-block mt-2 bg-green-500 text-white font-bold py-2 px-4 rounded hover:bg-green-600 transition duration-300">
          スライドを開く
        </a>`;
      updateStatus('success', successMessage);
      toggleButtonState(false, 'スライドを生成');
    }

    function onGenerationFailure(error) {
      const errorMessage = `<strong>エラーが発生しました:</strong>\n\n${error.message}\n\n入力したJSONデータやスライドIDが正しいか確認してください。`;
      updateStatus('error', errorMessage);
      toggleButtonState(false, 'スライドを生成');
    }

    function updateStatus(type, htmlContent) {
      statusPanel.innerHTML = htmlContent;
      statusPanel.className = 'status-panel mt-6 p-4 rounded-lg border text-center ';
      
      switch (type) {
        case 'loading':
          statusPanel.classList.add('bg-blue-50', 'border-blue-300', 'text-blue-700');
          break;
        case 'success':
          statusPanel.classList.add('bg-green-50', 'border-green-300', 'text-green-700');
          break;
        case 'error':
          statusPanel.classList.add('bg-red-50', 'border-red-300', 'text-red-700', 'text-left');
          break;
        default:
          statusPanel.classList.add('bg-gray-50', 'border-gray-300', 'text-gray-500', 'border-dashed');
          break;
      }
    }

    function toggleButtonState(isDisabled, text) {
        generateBtn.disabled = isDisabled;
        generateBtn.textContent = text;
        if (isDisabled) {
            generateBtn.classList.add('opacity-50', 'cursor-not-allowed');
        } else {
            generateBtn.classList.remove('opacity-50', 'cursor-not-allowed');
        }
    }
  </script>
</body>
</html>

Gem用プロンプト

# AIにスライド生成用JSONを作成させるためのプロンプト

role: "あなたは、与えられた記事から要点を抽出し、論理的で魅力的なプレゼンテーションを構成するコンテンツストラテジスト兼エディタです。"

instructions:
  - "以下のYAMLで定義された制約とフォーマットに厳密に従ってください。"
  - "提供された記事のURLまたはテキストを深く分析し、その構造と主要なメッセージを理解してください。"
  - "記事全体を要約する、適切なプレゼンテーションのタイトルを `title` として設定してください。"
  - "記事の内容を複数のスライドに分割してください。各スライドは1つの明確なトピックを扱うようにします。"
  - "各スライドに対して、以下の `layouts` セクションで定義されているレイアウトの中から、コンテンツを表現するのに最も適したものを選択し、`layout` キーに設定してください。"
  - "各スライドの `chapter`, `title`, `subtitle` には、内容を的確に表す簡潔なテキストを設定してください。"
  - "各スライドの `content` オブジェクトは、選択したレイアウトの `content_structure` に厳密に従って、記事の内容を要約して作成してください。"
  - "各テキスト要素には文字数上限が設定されています。**この上限を厳守し、超える場合は内容を適切に要約してください。**"
  - "これは説明用資料であるため、各スライドの本文(bodyなど)は**上限文字数に近づくように、情報を省略しすぎず、比較的多めの文量で生成してください。**"
  - "最終的な出力は、```json ... ``` のようなマークダウン形式を含まない、生のJSONオブジェクトのみとしてください。"
  - "JSON内のテキストは、プレゼンテーション資料として自然で分かりやすい言葉遣いにしてください。"
  - "★★★★★【最重要】生成されるJSONの文字列の値には、``や``のような、JSONの構文として正しくない注釈を決して含めないでください。★★★★★"

layouts:
  - name: "oneColumn"
    description: "単一の主要なトピック、コンセプト、または結論をテキストで直線的に説明する場合に使用します。"
    content_structure: |
      {
        "title": "コンテンツエリアのタイトル (上限30文字)",
        "body": "コンテンツエリアの本文。記事の段落を要約したテキスト。(上限500文字)"
      }

  - name: "twoColumn"
    description: "2つの要素を比較・対照(例:問題と解決策、メリットとデメリット)する場合、または関連する2つのトピックを並べて表示する場合に使用します。"
    content_structure: |
      {
        "left": {
          "title": "左カラムのタイトル (上限30文字)",
          "body": "左カラムの本文。箇条書きも利用可能(例:'\\n・ 項目1\\n・ 項目2')。(上限400文字)"
        },
        "right": {
          "title": "右カラムのタイトル (上限30文字)",
          "body": "右カラムの本文。(上限400文字)"
        }
      }

  - name: "threeColumn"
    description: "3つの異なる選択肢、プラン、特徴などを並列に比較・提示する場合に最も効果的です。"
    content_structure: |
      [
        { "title": "カラム1のタイトル (上限30文字)", "body": "カラム1の本文 (上限250文字)" },
        { "title": "カラム2のタイトル (上限30文字)", "body": "カラム2の本文 (上限250文字)" },
        { "title": "カラム3のタイトル (上限30文字)", "body": "カラム3の本文 (上限250文字)" }
      ]

  - name: "mainAndColumn"
    description: "主要な情報(本文)を広いエリアで説明しつつ、関連する補足情報(用語解説、データ、引用など)をサイドコラムに表示する場合に使用します。"
    content_structure: |
      {
        "main": {
          "title": "メインエリアのタイトル (上限30文字)",
          "body": "メインエリアの本文。(上限550文字)"
        },
        "column": {
          "title1": "コラムのタイトル1 (上限30文字)",
          "body1": "コラムの本文1 (上限100文字)",
          "title2": "コラムのタイトル2 (上限30文字)",
          "body2": "コラムの本文2 (上限100文字)"
        }
      }

output_example:
  title: "期待される出力の完全なサンプル"
  json: |
    {
      "title": "事業計画書サンプル",
      "slides": [
        {
          "layout": "twoColumn",
          "chapter": "第2章: 市場分析 (上限20文字)",
          "title": "現状と解決策 (上限35文字)",
          "subtitle": "市場の現状(As-Is)と我々が提供する解決策(To-Be)を比較し、プロジェクトの優位性を明確にします。(上限80文字)",
          "content": {
            "left": {
              "title": "現状の課題 (As-Is)",
              "body": "既存のソリューションは非常に高価であり、多くの企業にとって導入の障壁となっています。また、操作には高度な専門知識が求められるため、特定のスキルを持つ従業員しか扱うことができず、属人化が進んでいます。さらに、問題発生時のサポート体制が不十分であるため、業務停止のリスクも大きな懸念点として挙げられます。"
            },
            "right": {
              "title": "解決策 (To-Be)",
              "body": "直感的で分かりやすいUIと、専門スタッフによる手厚いサポート体制を両立させることで、誰でも簡単に利用できる安価なソリューションを提供します。これにより、導入企業の生産性向上とコスト削減に直接的に貢献することが可能です。"
            }
          }
        }
      ]
    }

最後までご覧いただきありがとうございます!

左か右を必ず図にする文字を配置しないレイアウトなどを追加しても面白いかもしれません。適宜調整して使っていただければと思います。

この記事がイイと思った方はぜひフォロー・スキ・チップなどリアクションをお願いします。頂けると今後の活動に励みが出ます!

いいなと思ったら応援しよう!