概要

項目内容
案件IDMAS-157
カテゴリ外部データ取込
PhaseP1.5
優先度★★★
所要時間1-2時間
対象ファイル500_import/502_receipt_reader.js (既存修正)
前提案件なし(独立着手可能)

目的

現行の 502_receipt_reader.js は PDF のみ対応しているが、スマホで撮影した領収書・レシートの写真(JPEG/PNG)も Gemini OCR で読み取れるよう拡張する。加えて、電帳法スキャナ保存の品質要件(解像度・カラー)を自動チェックし、不適合な画像には再撮影を促す警告を出す。

Gemini API は既に画像入力をサポートしており、主な変更は MIME タイプ判定の拡張と inline_data の動的設定のみ。抽出プロンプトは既存をそのまま流用可能(ADR-0007 補足決定 参照)。

現在のコード

1. ファイル取得(PDF のみ) — 502_receipt_reader.js:39

var files = folder.getFilesByType(MimeType.PDF);

MimeType.PDF 固定のため、JPEG/PNG ファイルは取得されない。

2. MIME タイプ固定 — 502_receipt_reader.js:197

{ inline_data: { mime_type: 'application/pdf', data: base64Pdf } }

mime_type'application/pdf' にハードコードされており、画像を渡す場合は動的に設定する必要がある。

3. メニュー表示名 — 101_sys_config.js:319

.addItem('📄 領収書PDFの読み込み (Drive)', 'importReceiptPdfs')

「PDF」という表記が名称に含まれている。画像対応後は「証憑」に変更すべき。

4. 関数名・ファイルヘッダー — 502_receipt_reader.js:1-4, 12

// 📄 05_receipt_reader.js — 領収書PDF読み込み (Google Drive + Gemini API)
// 指定フォルダ内のPDFを Gemini で解析し、receipt タブに出力
function importReceiptPdfs() {

コメントと関数名が PDF 前提。画像を含む汎用名に更新する。

修正方針

Step 1: ファイル取得の MIME タイプ拡張

getFilesByType() は単一 MIME タイプしか受け付けないため、対応する全 MIME タイプのファイルを収集する。

// --- フォルダから証憑ファイル取得(PDF + 画像) ---
var SUPPORTED_MIMES = [
  MimeType.PDF,        // application/pdf
  MimeType.JPEG,       // image/jpeg
  MimeType.PNG         // image/png
];

var allFiles = [];
for (var mi = 0; mi < SUPPORTED_MIMES.length; mi++) {
  var iter = folder.getFilesByType(SUPPORTED_MIMES[mi]);
  while (iter.hasNext()) {
    allFiles.push(iter.next());
  }
}

if (allFiles.length === 0) return ui.alert('📄 フォルダ内に対応ファイルがありません。\n対応形式: PDF, JPEG, PNG');

Step 2: MIME タイプの動的設定

callGeminiForReceipt_() に MIME タイプを引数として渡し、inline_data で動的に設定する。

// 呼び出し側(importReceiptPdfs 内)
var blob = file.getBlob();
var mimeType = blob.getContentType();  // 'application/pdf', 'image/jpeg', 'image/png'
var base64 = Utilities.base64Encode(blob.getBytes());

// 画像品質チェック(画像ファイルのみ)
if (mimeType !== 'application/pdf') {
  var qualityResult = checkImageQuality_(blob, mimeType);
  if (qualityResult.warnings.length > 0) {
    Utils.logInfo(FUNC, '⚠️ ' + fileName + ': ' + qualityResult.warnings.join(', '));
    if (qualityResult.reject) {
      errors.push(fileName + ': 品質不適合 - ' + qualityResult.warnings.join(', '));
      errorCount++;
      continue;
    }
  }
}

var extractedList = callGeminiForReceipt_(apiKey, base64, mimeType, fileName);

callGeminiForReceipt_() のシグネチャ変更:

// 変更前
function callGeminiForReceipt_(apiKey, base64Pdf, fileName) {
// 変更後
function callGeminiForReceipt_(apiKey, base64Data, mimeType, fileName) {

ペイロード内の mime_type を動的に:

// 変更前
{ inline_data: { mime_type: 'application/pdf', data: base64Pdf } }
// 変更後
{ inline_data: { mime_type: mimeType, data: base64Data } }

Step 3: 電帳法品質チェック関数の追加

電帳法スキャナ保存要件に基づく画像品質チェックを行うヘルパー関数を追加する。

/**
 * 画像の電帳法スキャナ保存品質をチェック
 * @param {GoogleAppsScript.Base.Blob} blob - 画像Blob
 * @param {string} mimeType - MIMEタイプ
 * @returns {{ warnings: string[], reject: boolean }}
 */
function checkImageQuality_(blob, mimeType) {
  var warnings = [];
  var reject = false;
  var bytes = blob.getBytes();

  if (mimeType === MimeType.JPEG || mimeType === 'image/jpeg') {
    var dims = getJpegDimensions_(bytes);
    if (dims) {
      // 電帳法: 解像度200dpi以上
      // レシート想定サイズ: 約80mm x 200mm (3.15" x 7.87")
      // 200dpi × 3.15" ≈ 630px (短辺の最低ライン)
      var minDimension = Math.min(dims.width, dims.height);
      if (minDimension < 630) {
        warnings.push('解像度不足の可能性(短辺' + minDimension + 'px, 推奨630px以上)');
        reject = true;
      }
      // グレースケール判定(JPEGコンポーネント数)
      if (dims.components === 1) {
        warnings.push('グレースケール画像(カラー必須)');
        reject = true;
      }
    }
  } else if (mimeType === MimeType.PNG || mimeType === 'image/png') {
    var pngDims = getPngDimensions_(bytes);
    if (pngDims) {
      var minDimPng = Math.min(pngDims.width, pngDims.height);
      if (minDimPng < 630) {
        warnings.push('解像度不足の可能性(短辺' + minDimPng + 'px, 推奨630px以上)');
        reject = true;
      }
      // PNGカラータイプ: 0=グレースケール, 2=RGB, 3=パレット, 4=グレー+α, 6=RGBA
      if (pngDims.colorType === 0 || pngDims.colorType === 4) {
        warnings.push('グレースケール画像(カラー必須)');
        reject = true;
      }
    }
  }

  // ファイルサイズチェック(極端に小さい画像の警告)
  if (bytes.length < 10000) {
    warnings.push('ファイルサイズが極端に小さい(' + Math.round(bytes.length / 1024) + 'KB)');
    // reject はしない(Gemini に渡して判断させる)
  }

  return { warnings: warnings, reject: reject };
}

/**
 * JPEGバイナリからピクセル寸法とコンポーネント数を取得
 * SOFn マーカー (0xFFC0-0xFFC3) をパースする
 * @param {number[]} bytes
 * @returns {{ width: number, height: number, components: number }|null}
 */
function getJpegDimensions_(bytes) {
  if (bytes.length < 2 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return null;
  var i = 2;
  while (i < bytes.length - 1) {
    if (bytes[i] !== 0xFF) { i++; continue; }
    var marker = bytes[i + 1] & 0xFF;
    // SOF0-SOF3 (0xC0-0xC3)
    if (marker >= 0xC0 && marker <= 0xC3) {
      if (i + 9 >= bytes.length) return null;
      var height = ((bytes[i + 5] & 0xFF) << 8) | (bytes[i + 6] & 0xFF);
      var width  = ((bytes[i + 7] & 0xFF) << 8) | (bytes[i + 8] & 0xFF);
      var components = bytes[i + 9] & 0xFF;
      return { width: width, height: height, components: components };
    }
    // その他のマーカー: セグメント長を読んでスキップ
    if (marker === 0xD9 || marker === 0xDA) break; // EOI or SOS
    if (i + 3 >= bytes.length) break;
    var segLen = ((bytes[i + 2] & 0xFF) << 8) | (bytes[i + 3] & 0xFF);
    i += 2 + segLen;
  }
  return null;
}

/**
 * PNGバイナリからピクセル寸法とカラータイプを取得
 * IHDRチャンクをパースする
 * @param {number[]} bytes
 * @returns {{ width: number, height: number, colorType: number }|null}
 */
function getPngDimensions_(bytes) {
  // PNG signature: 137 80 78 71 13 10 26 10
  if (bytes.length < 24) return null;
  if (bytes[0] !== 137 || bytes[1] !== 80 || bytes[2] !== 78 || bytes[3] !== 71) return null;
  // IHDR starts at byte 8 (length) + 4 (type) + data
  // Width: bytes 16-19, Height: bytes 20-23, ColorType: byte 25
  var width  = ((bytes[16] & 0xFF) << 24) | ((bytes[17] & 0xFF) << 16) | ((bytes[18] & 0xFF) << 8) | (bytes[19] & 0xFF);
  var height = ((bytes[20] & 0xFF) << 24) | ((bytes[21] & 0xFF) << 16) | ((bytes[22] & 0xFF) << 8) | (bytes[23] & 0xFF);
  var colorType = bytes.length > 25 ? (bytes[25] & 0xFF) : -1;
  return { width: width, height: height, colorType: colorType };
}

Step 4: コメント・関数名・メニューの更新

502_receipt_reader.js ファイルヘッダー

// 変更前
// 📄 05_receipt_reader.js — 領収書PDF読み込み (Google Drive + Gemini API)
// 指定フォルダ内のPDFを Gemini で解析し、receipt タブに出力

// 変更後
// 📄 05_receipt_reader.js — 証憑読み込み (Google Drive + Gemini API)
// 指定フォルダ内のPDF・画像(JPEG/PNG)を Gemini で解析し、receipt タブに出力

importReceiptPdfs() の JSDoc 更新

// 変更前
/**
 * メニュー: 📄 領収書PDFの読み込み (Drive)
 * スクリプトプロパティに必要な設定:
 *   GEMINI_API_KEY: Gemini API のキー
 *   RECEIPT_FOLDER_ID: 領収書PDFを入れるGoogle DriveフォルダのID
 */

// 変更後
/**
 * メニュー: 📄 証憑の読み込み (Drive)
 * PDF・画像(JPEG/PNG)に対応。画像は電帳法スキャナ保存の品質チェックを実施。
 * スクリプトプロパティに必要な設定:
 *   GEMINI_API_KEY: Gemini API のキー
 *   RECEIPT_FOLDER_ID: 証憑ファイルを入れるGoogle DriveフォルダのID
 */

関数名 importReceiptPdfs は変更しない。 既存メニュー登録やドキュメントからの参照があるため、後方互換性を維持する。

101_sys_config.js メニュー表示名の更新

// 変更前
.addItem('📄 領収書PDFの読み込み (Drive)', 'importReceiptPdfs')
// 変更後
.addItem('📄 証憑の読み込み (Drive: PDF/写真)', 'importReceiptPdfs')

Step 5: 結果ダイアログの更新

// 変更前
if (pdfFiles.length === 0) return ui.alert('📄 フォルダ内にPDFがありません。');
// ...
var msg = '📄 読み込み完了\n✅ 成功: ' + successCount + '件\n❌ 失敗: ' + errorCount + '件';
// ...
msg += '\n\n処理済みPDFは「processed」フォルダに移動しました。';
ui.alert('📄 領収書PDF読み込み 結果', msg, ui.ButtonSet.OK);

// 変更後
if (allFiles.length === 0) return ui.alert('📄 フォルダ内に対応ファイルがありません。\n対応形式: PDF, JPEG, PNG');
// ...
var msg = '📄 読み込み完了\n✅ 成功: ' + successCount + '件\n❌ 失敗: ' + errorCount + '件';
// ...
msg += '\n\n処理済みファイルは「processed」フォルダに移動しました。';
ui.alert('📄 証憑読み込み 結果', msg, ui.ButtonSet.OK);

影響範囲

変更対象変更内容変更量
500_import/502_receipt_reader.jsMIME拡張 + 品質チェック追加 + コメント更新~120行追加
100_config/101_sys_config.jsメニュー表示名変更1行変更
  • 既存動作への影響なし: PDF の処理ロジックは変更しない。MIME タイプ判定の拡張と品質チェックの追加のみ
  • callGeminiForReceipt_() のシグネチャに mimeType 引数が追加されるが、この関数はプライベート関数(末尾 _)であり外部呼び出しはない
  • postProcessReceiptData_() は変更不要(画像由来のデータも同じフォーマットで receipt タブに書き込まれるため)

注意事項

  1. HEIC (iPhone 標準形式) は初期対応から除外する。 Google Drive にアップロードされた時点で JPEG に自動変換されるケースが多い。変換されない場合は手動で JPEG に変換してもらう運用とする。HEIC 対応は運用実績を見て判断(GASMimeType に HEIC 定数がないため、文字列 'image/heif' での追加が必要になる)
  2. getJpegDimensions_() / getPngDimensions_() はバイナリパースのため、破損ファイルではnullを返す。 null の場合は品質チェックをスキップし、Gemini に渡して処理させる(品質チェック失敗でファイルを拒否しない)
  3. 解像度チェックの閾値 630px は「レシートの短辺 ≈ 80mm ≈ 3.15インチ × 200dpi」から算出。 A4 請求書(短辺210mm)の場合は 1654px が基準だが、レシートを想定して低めに設定。閾値は定数化しておき、運用で調整可能にする
  4. 1 枚に複数レシートが写っている場合: Gemini の抽出プロンプトに「複数ページの場合あり」の指示が既にあるため、1 画像から複数レコードが返る可能性がある。既存の extractedList ループで正しく処理される
  5. 変数名 pdfFilesallFiles に変更する。 画像も含むため、変数名を汎用化する
  6. 関数名 importReceiptPdfs は変更しない。 既にメニュー・ドキュメントから参照されており、関数名変更は影響範囲が広い。JSDoc とコメントで画像対応済みであることを明記する

実データ検証(MCP でのデータ確認が必要な場合)

確認項目確認方法理由
Google Drive フォルダ内のファイル形式対象フォルダを手動確認JPEG/PNG 以外の形式(HEIC, WEBP 等)が存在するか確認
35_wrk_receipt のヘッダー構成MCP で 35 タブのヘッダー行を取得DDL 定義と実データの列順一致を確認
既存 PDF 取込結果の品質35 タブの既存データを確認画像 OCR 結果と同等の品質が期待できるか基準を把握

関連ドキュメント

仕様書関連箇所
ADR-0007: Gemini API 領収書解析補足決定: MAS-157 は Gemini Flash を継続使用(MIME タイプ拡張のみ)
CLAUDE.mdEnv.geminiApiKey() / Env.receiptFolderId() の使用規約
spec_receipt_import.md領収書取込フローの業務仕様
dev_mas-146_cc_auto_settlement.mdMAS-157 の画像 OCR 結果はクレカ消込にも利用可能

人間が検討すべき事項

#項目詳細
1HEIC (iPhone 標準形式) → JPEG 変換の要否Google Drive 自動変換に依存するか、GAS 側で 'image/heif' を追加対応するか。TODO_future.md から転記
2解像度チェックの閾値200dpi 相当のピクセル数をレシートサイズから逆算。本仕様では短辺 630px(レシート80mm想定)としたが、A4 請求書なら 1654px。運用で調整要。TODO_future.md から転記
31 枚に複数レシートが写っている場合の扱い既存プロンプトの「ページごとに1つの JSON」で対応可能だが、精度は未検証。TODO_future.md から転記
4タイムスタンプ要件への対応方針Drive 更新日時で代替可能か、別途タイムスタンプサービスが必要か。TODO_future.md から転記
5品質チェックの reject 運用品質不適合時にファイルを完全拒否するか、警告付きで処理を続行するかの判断。本仕様では reject=true(拒否)としたが、運用開始時は警告のみ(reject=false)にして様子を見る選択肢もある

実装プロンプト(Claude Code 用)

あなたはGAS会計システム(bizlp-gas-accounting)のシニア開発者です。
案件 MAS-157「写真(JPEG/PNG)対応の証憑OCR + 電帳法品質チェック」を実装してください。

## 実行前タスク
以下のファイルを読み込んでください:
1. `500_import/502_receipt_reader.js` — 現行の領収書PDF読み込み実装。以下を重点確認:
   - `importReceiptPdfs()` (L12-175): メイン関数。ファイル取得→Gemini呼び出し→シート書き込み
   - `callGeminiForReceipt_()` (L184-281): Gemini API呼び出し。mime_type がハードコード
   - `postProcessReceiptData_()` (L288-361): 同一取引先の突合補正(変更不要)
2. `100_config/101_sys_config.js` — L319 のメニュー表示名を変更
3. `CLAUDE.md` — コーディング規約
4. `docs/dev/dev_mas-157_photo_ocr.md` — 本仕様書

## 修正対象ファイル
- `500_import/502_receipt_reader.js` — 既存ファイルの修正
- `100_config/101_sys_config.js` — メニュー表示名の1行変更のみ

## 実装内容

### A: `502_receipt_reader.js` の修正

1. **ファイルヘッダーコメント更新** (L1-4):
   - 「領収書PDF読み込み」→「証憑読み込み」
   - 「指定フォルダ内のPDFを」→「指定フォルダ内のPDF・画像(JPEG/PNG)を」

2. **JSDoc 更新** (L6-11):
   - メニュー名更新、画像対応・電帳法チェックの記述追加
   - `RECEIPT_FOLDER_ID` の説明を「証憑ファイルを入れる」に変更

3. **SUPPORTED_MIMES 定数の追加** (L39 の前):
   ```js
   var SUPPORTED_MIMES = [MimeType.PDF, MimeType.JPEG, MimeType.PNG];
   ```

4. **ファイル取得の変更** (L39-43):
   ```js
   // 変更前
   var files = folder.getFilesByType(MimeType.PDF);
   var pdfFiles = [];
   while (files.hasNext()) { pdfFiles.push(files.next()); }
   // 変更後
   var allFiles = [];
   for (var mi = 0; mi < SUPPORTED_MIMES.length; mi++) {
     var iter = folder.getFilesByType(SUPPORTED_MIMES[mi]);
     while (iter.hasNext()) { allFiles.push(iter.next()); }
   }
   ```

5. **変数名 `pdfFiles` → `allFiles` に全置換** (L45, L89 等)

6. **空ファイルメッセージ更新** (L45):
   ```js
   if (allFiles.length === 0) return ui.alert('📄 フォルダ内に対応ファイルがありません。\n対応形式: PDF, JPEG, PNG');
   ```

7. **Gemini 呼び出し前に品質チェックを挿入** (L94-97 の後):
   ```js
   var blob = file.getBlob();
   var mimeType = blob.getContentType();
   var base64 = Utilities.base64Encode(blob.getBytes());

   // 画像品質チェック(PDF以外)
   if (mimeType !== 'application/pdf') {
     var qualityResult = checkImageQuality_(blob, mimeType);
     if (qualityResult.warnings.length > 0) {
       Utils.logInfo(FUNC, '⚠️ ' + fileName + ': ' + qualityResult.warnings.join(', '));
       if (qualityResult.reject) {
         errors.push(fileName + ': 品質不適合 - ' + qualityResult.warnings.join(', '));
         errorCount++;
         continue;
       }
     }
   }
   ```

8. **callGeminiForReceipt_ 呼び出しに mimeType を追加** (L99):
   ```js
   var extractedList = callGeminiForReceipt_(apiKey, base64, mimeType, fileName);
   ```

9. **callGeminiForReceipt_ のシグネチャと payload 修正** (L184, L197):
   - 引数: `base64Pdf` → `base64Data`, `mimeType` 追加
   - payload: `mime_type: 'application/pdf'` → `mime_type: mimeType`
   - 変数名: `base64Pdf` → `base64Data`

10. **品質チェック関数3つを末尾に追加**:
    - `checkImageQuality_(blob, mimeType)` — 電帳法品質チェック
    - `getJpegDimensions_(bytes)` — JPEG バイナリパース
    - `getPngDimensions_(bytes)` — PNG バイナリパース

11. **結果ダイアログの更新** (L167-169):
    - 「処理済みPDFは」→「処理済みファイルは」
    - ダイアログタイトル「領収書PDF読み込み 結果」→「証憑読み込み 結果」

12. **ページ数警告のコメント修正** (L110-116): 画像の場合はページ数チェック不要なので、PDF の場合のみ実行する条件を追加

### B: `101_sys_config.js` の修正

L319 のメニュー表示名を変更:
```js
.addItem('📄 証憑の読み込み (Drive: PDF/写真)', 'importReceiptPdfs')
```

## 制約
- 関数名 `importReceiptPdfs` は変更しない(後方互換性維持)
- `callGeminiForReceipt_()` のプロンプト文は変更しない(既存の抽出指示で画像にも対応可能)
- `postProcessReceiptData_()` は変更しない
- HEIC 形式は対応しない(Google Drive の自動変換に依存)
- `getJpegDimensions_` / `getPngDimensions_` がnullを返した場合、品質チェックをスキップして処理を続行する(ファイルを拒否しない)

## 動作確認
`npm run push:dev` 後:
1. メニュー「🔍 消込・マッチング」に「📄 証憑の読み込み (Drive: PDF/写真)」が表示されること
2. Drive フォルダに JPEG 画像(レシート写真)を配置
3. 「📄 証憑の読み込み」を実行
4. **検証**: JPEG ファイルが読み取られ、35_wrk_receipt に正しくデータが追加されること
5. **検証**: 低解像度画像(短辺 < 630px)で品質警告が出ること
6. **検証**: 処理済みファイルが processed フォルダに移動すること
7. 既存の PDF ファイルでも正常に動作すること(回帰テスト)

### 拡張思考の使用状況

| フェーズ | 拡張思考 | 備考 |
|---------|:--------:|------|
| ファイル読み込み・構造理解 | あり | 既存コードの全体構造把握 |
| MIME タイプ拡張 | なし | 仕様書で変更箇所が特定済み |
| 品質チェック関数の実装 | あり | バイナリパースのロジック確認 |
| メニュー・コメント更新 | なし | 定型作業 |

推奨実行モデル

工程推奨モデル理由
仕様書作成(本ドキュメント)Claude Opus 4.6既存コードの分析、電帳法要件の設計、バイナリパースの正確性に高い推論力が必要
実装Claude Sonnet 4.6変更箇所は仕様書で特定済みだが、バイナリパース関数の正確な実装に中程度の判断力が必要
動作確認ユーザー手動Drive フォルダへの画像配置 → 実行 → 結果確認の手動操作が必要

変更履歴

日付変更内容
2026-04-16初版作成