Google Apps Script (GAS) と Google Cloud Vision APIを用いてPDFと画像からのテキスト抽出

本記事では、Google Apps Script (GAS) と Google Cloud Vision API を用いてPDFファイルや画像ファイルからテキストを抽出する方法について解説します。



# ChatGPTを用いたプログラミングのための前提知識
このセクションの説明はChatGPTの回答(2023/6/6時点)をベースに適宜加筆・修正しています。
### セッションとトークンの違いについて
　ChatGPTにおける「セッション」と「トークン」は、会話とその内部表現の2つの異なる側面を指します。
- **セッション**: セッションとは、ユーザーとChatGPT間の一連の会話を指します。これには、ユーザーの質問やコメントと、それに対するChatGPTの応答が含まれます。セッションは会話の開始と終了によって定義され、会話の終了後にはそのセッション中の情報は破棄されます。
- **トークン**: トークンとは、テキストをChatGPTが理解可能な部分（単位）に分割する方法を指します。英語では、トークンは大体が単語や句読点ですが、GPT-3やGPT-4のような多言語モデルでは、1つのトークンは単語の一部（サブワード）を含むことがあります。トークンはモデルがテキストを処理するための基本的な単位であり、モデルは一度に特定数のトークンしか処理できません。[GPT-3.5のトークンの上限](https://platform.openai.com/docs/models/gpt-3-5)は4,096トークン、[GPT-4のトークンの上限](https://platform.openai.com/docs/models/gpt-4)は8,192トークンです。

これら二つは完全に異なる概念です。セッションは会話の文脈を保持するための方法であり、一方、トークンはモデルがテキストを理解し処理するための基本的な単位です。

### セッションはいつ切れるのか
　ChatGPTのセッションが切れる典型的な状況は以下のようなものです。
1. ユーザーがチャットウィンドウを閉じる。
2. ユーザーがウェブサイトやアプリケーションから離れる。
3. ユーザーが明示的に会話を終了またはログアウトする。
4. 長期間（例えば15分や30分など）、ユーザーからの入力がない場合（タイムアウト）。
5. サーバーやアプリケーション側で何らかのエラーが発生し、セッションが中断される。

# ChatGPTを使ったプログラムの作成にあたって
　このWEBアプリはChatGPT(GPT-4)を使って作成しています。著者は上述のセッションとトークンを混同していたため、トークンの上限を超えないよう、最初にプログラムの大まかな枠組みを作り、その後、細かい機能を追加しながら動作確認を行い、最後に全体を通じて最適化するという流れで作成しました。日を跨いでも、同じチャット内であれば開発が続けられると思っていましたが、そうではないようですね。次回以降の開発に活かしたいと思います。
　GPT-4を使う場合、現在の制限は「[3時間毎に最大25メッセージ](https://xrcloud.jp/blog/articles/business/8547/)」とあるので、これを超えないようにゆっくり使い続ければ、同一セッション内で開発を続けることができますが、これを超えてしまうとセッションが切れる、つまりそれまでのやり取りは失われると考えた方が良さそうです。
　とはいえ、プログラムの規模が大きければトークンの上限を考慮に入れる必要が出てきますし、現在のGPT-4の回答を生成するスピードを考えると、こちらが提示するコードもChatGPTの回答も変更が必要な部分に焦点化して進めるといった工夫をするとよいでしょう。ただし、ChatGPTから提示されるコードは部分的なものであるため、プログラミングに関するある程度の知識があることが望ましいですが、指示された変更箇所が見つけられない時には、例えば修正コードを含む特定の関数を全部出力してもらうことも必要になると思われます。
　なお、ChatGPTが生成するコードは100%正確ではないため、このWEBアプリのように実際に正しく動作するものを作成し、それをWEB上で共有することは、少なくともWEB上にある古い知識を更新し、複数の情報を組み合わせた新たな知識が供給されるという点で無意味ではないと思っています。

# ChatGPTによるコーディング奮闘記（完成までのプロンプト概要）
以下は、WEBアプリの作成過程で指示したプロンプトの概要です。
1. 【方針の策定】「GASでアップロードされた画像からOCR機能を用いて文字を抽出し、その結果を表示するWEBアプリを作成したい。作れる？」
    - 画像に限定したWEBアプリから始める。「作れる？」と聞いて、まず実現可能性をチェックするところから始める。もっとスマートなソリューションを示してくれることもある。
2. 【コードの提示、仕込み】「上記を実現するコードを示して下さい。Cloud Vision APIを取得する方法についても示して下さい。」
    - 提示された作成方針で良ければ、コードを示してもらう。提案された別の方針の方が良ければ、その部分を引用して、「こちらの方針によるコードを示して」などと指示する。
3. 【テスト】「示されたコードを試すにはどうすればいいですか？」
    - index.htmlとcode.jsの2つのファイルが必要となるので、このように聞けば、プロジェクトの作成方法から手順を追って示してくれる。
4. 【簡単な改善】「OCR結果をログに出力していますが、HTMLに表示するように変更して下さい。」「デプロイ後にコードを修正したら、再度デプロイし直す必要がありますか。」「ボタンは一つのままPNGだけでなく、JPEGとPDFも扱えるようにしたい。」等々
    - 正常に動作することが確認できたら、このように細かい改善を積み重ねていく。開発モードのウェブアプリであれば、コード修正した後にリロードすれば即座に変更が反映される。
1. 【根気のいる改善】PDFへの対応を実装している箇所で頻繁にエラーが出た。主な原因は、GASではGoogle Cloud Storageにpdfをアップロードしその後、Vision APIを用いる必要があるが、Cloud Storageに直接アクセスができないとのこと。いくつか方針が示されたが、JavaScriptのPDFライブラリ（pdf.js)を使ってブラウザ上でPDFを画像に変換し、その画像をVision APIに渡すことにしたのだが、この方針で示されたコードのバグ取りには結構時間がかかった。セッションを再開する時には、前提知識をプロンプトで確認するところから始める必要があるが、プログラミングについて言えば、必要があれば全てのコードを一度、入力しておけば十分。
1. 【根気のいる改善】非同期処理だったため、ページ番号順ではなく処理が終わった順に表示されてしまう。この箇所の修正にもやや手こずった。
1. 【仕上げ】完成後は「varではなくconstとletを使い、V8ランタイムに準拠した形式でコードの最適化をして下さい」と指示し、提示されたコードで再度、動作確認をして完成。GASのV8ランタイムに準拠したコードがネット上に広まることで、さらなる知識の循環が生まれることを期待しつつ。



# コード全体の解説
　このコードでは、Google Cloud Vision APIを使用して、Webページにアップロードされた画像からテキストを抽出し、そのテキストをWebページ上に表示する処理を行います。
### Google Cloud Vision APIキーの取得
以下の手順でGoogle Cloud Vision APIキーを取得します。詳細な手順はGoogle Cloudの[公式ドキュメント](https://cloud.google.com/vision/docs/setup?hl=ja)にもありますが、基本的な手順をここで説明します。

1. [Google Cloud Console](https://console.cloud.google.com/) にログインします。新しいプロジェクトを作成するか、既存のプロジェクトを選択します。
2. プロジェクトダッシュボードから左のナビゲーションメニューを開き、「APIとサービス」 > 「認証情報」を選択します。
3. 「認証情報を作成」ボタンをクリックし、「APIキー」を選択します。
4. APIキーが生成され、画面に表示されます。このキーをコピーして保存します。このAPIキーは後でcode.jsに設定します。

### Code.jsの解説
　最初に、Google Cloud Vision APIのキーをAPI_KEYという定数に設定します。これはAPIへのアクセスを認証するためのキーです。
　doGet関数は前述のindex.htmlという名前のHTMLファイルを用いてウェブアプリケーションを作成します。このウェブアプリケーションは、そのウェブアプリケーションが開かれたとき（GETリクエストが送信されたとき）にindex.htmlを表示します。
　processImage関数は、画像データ（Base64形式）とファイルのタイプを引数として受け取ります。この関数は、Google Cloud Vision APIにリクエストを送信して、画像内のテキストを抽出します。抽出したテキストは、関数の戻り値として返されます。
```javascript:Code.gs
const API_KEY = 'YOUR_CLOUD_VISION_API_KEY'; // Cloud Vision APIのキー設定

// GETリクエスト処理
function doGet() {
  // index.htmlを出力し、クロスオリジンのフレームを許可
  return HtmlService.createHtmlOutputFromFile('index.html').setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// 画像処理関数定義
function processImage(base64Image, fileType) {
  try {
    // Google Cloud Vision APIのエンドポイント
    const url = 'https://vision.googleapis.com/v1/images:annotate?key=' + API_KEY;

    // APIリクエストのbody設定
    const body = {
      requests: [{
        image: {
          content: base64Image
        },
        features: [{
          type: 'TEXT_DETECTION'  // テキスト検出のタイプ指定
        }]
      }]
    };

    // POSTリクエストのオプション設定
    const options = {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(body)
    };
    
    // APIリクエストの送信
    const response = UrlFetchApp.fetch(url, options);
    // レスポンスのパース
    const result = JSON.parse(response.getContentText());

    // テキストの抽出
    if (result.responses && result.responses[0] && result.responses[0].fullTextAnnotation) {
      return result.responses[0].fullTextAnnotation.text;
    } else {
      return 'No text found';
    }
  } catch (e) {
    // エラーハンドリング
    console.error(e);
    return "Error occurred: " + e.toString();
  }
}
```

### index.htmlの解説
　PDFや画像（PNG/JPEG）からテキストを抽出するウェブアプリケーションのフロントエンド部分を構築するものです。各部分を順に説明します。
　head要素では、スクリプトとCSSスタイルシートを読み込んでいます。この中には、PDF.jsというライブラリを読み込むためのスクリプト要素と、BootstrapというCSSフレームワークを読み込むリンク要素が含まれています。
　ファイルアップロードエリアでは、ユーザーがPDFまたは画像ファイルを選択できるようになっています。選択されたファイル名はファイル選択エリアのラベルに表示されます。そして、「送信」ボタンが設置されており、押されるとファイルのテキスト抽出処理が開始されます。
　JavaScript部分ではPDF.jsライブラリを利用するための設定が行われています。また、ファイル選択時や送信ボタンクリック時に発生するイベントに対してリスナーが設定されており、それぞれのイベント発生時に特定の処理が行われるように設定されています。具体的には、ファイル選択時には選択されたファイルの情報が保存され、送信ボタンがクリックされた時にはテキスト抽出処理が始まります。
　抽出プロセスはPDFと画像で異なります。PDFの場合、PDF.jsライブラリを使ってPDFの各ページからテキストが抽出されます。画像の場合、Google Apps Scriptのサービスが使われて画像からテキストを抽出します。最後に、抽出されたテキストは結果表示エリアに表示されます。
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <!-- PDF.jsの読み込み -->
    <script src="https://mozilla.github.io/pdf.js/build/pdf.js"></script>
    <!-- Bootstrapの読み込み -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
    <!-- ベースターゲットの設定 -->
    <base target="_top">
  </head>
  <body class="container mt-5">
    <!-- タイトル -->
    <h1 class="mb-4">PDF and Image Text Extractor</h1>
    <!-- アプリの説明 -->
    <p class="lead">このアプリを使うと、アップロードしたPDFファイルや画像（PNG/JPEG）からテキストを抽出することができます。</p>
    <p class="lead">ファイルを選択し、「送信」ボタンをクリックすると、抽出されたテキストと元の画像が表示されます。</p>
    <!-- 注意事項 -->
    <h5 class="mt-5 mb-3">注意事項：</h5>
    <ul>
      <li>このアプリケーションは15MB以下のファイルのみ対応しています。</li>
      <li>このアプリケーションはPDF, PNG, JPEG形式のファイルに対応しています。それ以外の形式はサポートしておりません。</li>
      <li>文字の位置、サイズ、書体、クリアさ等により、一部の文字が正確に認識されない可能性があります。</li>
    </ul>
    <!-- セキュリティ情報 -->
    <h5 class="mt-5 mb-3">安心してご利用いただくために：</h5>
    <ul>
      <li>このウェブアプリケーションは、アップロードしたファイルを一時的に使用するだけで、サーバーには保存しません。処理が完了するとファイルは完全に破棄されます。</li>
      <li>このアプリケーションは、Googleの高度なセキュリティ基盤上で運用されています。</li>
    </ul>
    <!-- ファイルアップロードエリア -->
    <div class="row">
    <div class="col-6">
        <div class="custom-file mt-4">
          <input type="file" class="custom-file-input" id="pdfFile" accept="application/pdf, image/png, image/jpeg">
          <label class="custom-file-label" for="pdfFile">ファイルを選択</label>
        </div>
    </div>
    </div>
    <!-- 送信ボタン -->
    <button id="submit" class="btn btn-primary mt-4">送信</button>
    <!-- 結果表示エリア -->
    <div id="results" class="mt-5"></div>
    <script>
      // ファイル選択時のイベントリスナー
      document.querySelector('.custom-file-input').addEventListener('change', function(e) {
      const fileName = document.getElementById("pdfFile").files[0].name;
      const nextSibling = e.target.nextElementSibling
      nextSibling.innerText = fileName
      })

      // PDF.jsのワーカースクリプトの設定
      pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.js';

      // ファイル変数の初期化
      let file; 

      // ファイル選択時のイベントリスナー
      document.getElementById('pdfFile').addEventListener('change', function(e) {
        file = e.target.files[0];
      });

      // ファイルサイズチェック関数
      function checkFile() {
        const maxFileSize = 15 * 1024 * 1024;
        if (file.size > maxFileSize) {
          console.error('The file is too large. Please select a file smaller than 15MB.');
          return false;
        }
        return true;
      }

      // ファイル処理関数
      async function processFile(result) {
        // PDFファイルの場合の処理
        if (file.type === 'application/pdf') {
          const typedarray = new Uint8Array(result);
          const pdf = await pdfjsLib.getDocument(typedarray).promise;
          // PDFページ毎の処理関数
          const processPage = async function(pageNum) {
            // その他の処理
          };

          // すべてのページを処理
          for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
            await processPage(pageNum);
          }
        } else if (file.type === 'image/png' || file.type === 'image/jpeg') {
            // 画像ファイルの場合の処理
            const base64Image = result.split(",")[1];
          google.script.run.withSuccessHandler((function(result) {
            // その他の処理
          })(result)).processImage(base64Image, file.type);
        }
      }

      // 送信ボタンクリック時のイベントリスナー
      document.getElementById('submit').addEventListener('click', async function() {
        // その他の処理
      });
    </script>
  </body>
</html>
```