Golang/OCRを利用した.pdf読み取り.xlsx入力自動化

# 概要

下記記事の拡張機能として、特定のディレクトリにアップされた領収書.pdfの記述内容をGolang/OCRライブラリを利用して読み取った上でファイル名等に使用、更に必要に応じて現金立替分報告用.xlsxに入力するところまでを自動化しました

https://qiita.com/hiltlinc-sys/items/e1d16621e6069f0e7628

※カード支払い分と現金立替分の領収書.pdfとではアップ先ディレクトリが異なる為、ディレクトリで各.pdfに対する処理を分岐します

# 手順
1. OCRライブラリを利用して.pdf記述内容を読み取る
2. 読み取った内容を適宜.xlsx入力
3. 1〜2を.pdf監視・圧縮プロジェクト本体に組み込む

## 1. OCRで.pdf記述内容を読み取る

下記ライブラリをインストールし、OCR処理を関数化

https://github.com/otiai10/gosseract

```golang:read_pdf.go
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strconv"

	"github.com/otiai10/gosseract/v2"
	"github.com/pdfcpu/pdfcpu/pkg/api"
	"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
)

// PDFから画像を抽出する関数
func GetImg(pdfPath, outputDir string) error {
	// 出力ディレクトリを作成
	err := os.MkdirAll(outputDir, 0755)
	if err != nil {
		return fmt.Errorf("ディレクトリ作成エラー: %w", err)
	}

	// PDF抽出用の設定
	conf := model.NewDefaultConfiguration()

	// PDFから画像を抽出
	err = api.ExtractImagesFile(pdfPath, outputDir, nil, conf)
	if err != nil {
		return fmt.Errorf("PDF画像抽出エラー: %w", err)
	}

	return nil
}

// 画像に対してOCRを実行（テキスト取得）
func RunOCR(pdfImgDir string) (map[string]string, error) {
	// OCR結果を格納するマップ
	results := make(map[string]string)

	// OCRクライアント初期化
	client := gosseract.NewClient()
	defer client.Close()

	client.SetLanguage("jpn") //日本語を指定

	// 画像ディレクトリ内のファイルを処理
	files, err := os.ReadDir(pdfImgDir)
	if err != nil {
		return nil, fmt.Errorf("画像ディレクトリ読み取りエラー: %w", err)
	}

	for _, file := range files {
		if file.Name() == ".DS_Store" && filepath.Ext(file.Name()) != ".png" && filepath.Ext(file.Name()) != ".jpg" {
			continue //非画像ファイルをスキップ
		}
		imagePath := filepath.Join(pdfImgDir, file.Name())

		// OCRクライアントに画像を設定
		err := client.SetImage(imagePath)
		if err != nil {
			return nil, fmt.Errorf("OCRクライアントエラー (%s): %w", imagePath, err)
		}

		// OCRを実行してテキストを取得
		text, err := client.Text()
		if err != nil {
			return nil, fmt.Errorf("OCRエラー (%s): %w", imagePath, err)
		}

		// 結果をマップに格納
		results[file.Name()] = text
	}

	return results, nil
}

// 正規表現を使ってテキストからマッチする部分を取得する関数
func FindPattern(pattern, text string) (string, error) {
	compiledRegexp, err := regexp.Compile(pattern)
	if err != nil {
		return "", fmt.Errorf("正規表現のコンパイルに失敗しました: %v", err)
	}
	return compiledRegexp.FindString(text), nil
}

// 日付をYYYYMMDD形式に変換
func ConvertDate(ocrText string) (string, error) {
	// 年、区切り文字（年/-）、月、区切り文字（月/-）、日、オプションの「日」
	re := regexp.MustCompile(`(\d{4})(?:[年/-])(\d{1,2})(?:[月/-])(\d{1,2})(?:日)?`)
	matches := re.FindStringSubmatch(ocrText)

	if len(matches) >= 4 {
		monthInt, _ := strconv.Atoi(matches[2])
		dayInt, _ := strconv.Atoi(matches[3])
		return fmt.Sprintf("%s%02d%02d", matches[1], monthInt, dayInt), nil
	}

	return "", fmt.Errorf("日付パターンが見つかりませんでした")
}
```

# 2. Excelizeを利用し.xlsxへの書き込み

下記ライブラリをインストールし、.xlsx更新処理を関数化

https://github.com/qax-os/excelize

```golang:update_xlsx.go
package main

import (
	"fmt"
	"log"
	"regexp"
	"strconv"

	"github.com/xuri/excelize/v2"
)

// Excel記入用にYYYYMMDDをYYYY/M/D形式に揃える
func XlsxFormatDate(yyyymmdd string) string {
	if len(yyyymmdd) != 8 {
		return yyyymmdd
	}
	year := yyyymmdd[:4]

	// 文字列から整数への変換・先頭ゼロの除去
	month, _ := strconv.Atoi(yyyymmdd[4:6])
	day, _ := strconv.Atoi(yyyymmdd[6:8])

	return fmt.Sprintf("%s/%d/%d", year, month, day)
}

// アップされた.pdfから該当月Excelファイルを取得
func findDir(fullPath string) string {

	// 正規表現でspendingまでのパスを抽出
	re := regexp.MustCompile(`(.+/spending/)`)
	matches := re.FindStringSubmatch(fullPath)
	if len(matches) > 1 {
		return matches[1] + "/kojin_tatekae.xlsx"
	}
	return ""
}

// セルへの書き込み
func formatCell(f *excelize.File, sheetName string, cellAddress string, value interface{}) error {

	// 既存のスタイルIDを取得
	styleID, err := f.GetCellStyle(sheetName, cellAddress)
	if err != nil {
		return fmt.Errorf("スタイルの取得に失敗: %w", err)
	} else {
		// スタイルを適用
		if err := f.SetCellStyle(sheetName, cellAddress, cellAddress, styleID); err != nil {
			return fmt.Errorf("スタイルの適用に失敗: %w", err)
		}
	}

	// 値を書き込み
	switch v := value.(type) {
	case string:
		if err := f.SetCellValue(sheetName, cellAddress, v); err != nil {
			return fmt.Errorf("値の書き込みに失敗 (string): %w", err)
		}
	case int:
		if err := f.SetCellValue(sheetName, cellAddress, v); err != nil {
			return fmt.Errorf("値の書き込みに失敗 (int): %w", err)
		}
	default:
		return fmt.Errorf("サポートされていない型: %T", value)
	}
	return nil
}

// メイン関数（Excelファイル更新）
func UpdateXlsx(eventPath string, yyyymmdd string, numPpl string, price int) {

	xlsxFile := findDir(eventPath)
	xlsxDate := XlsxFormatDate(yyyymmdd)

	// Excelで操作
	f, err := excelize.OpenFile(xlsxFile)
	if err != nil {
		fmt.Println("error opening excel file: %w", err)
	}
	defer f.Close()

	// シート名をインデックス指定して取得
	sheetName := f.GetSheetName(0)

	// B列の8行目以降をチェック
	row := 8 //開始行
	for {

		// 各セル列指定
		cellDate := fmt.Sprintf("B%d", row)
		cellContent := fmt.Sprintf("C%d", row)
		cellEntertainEx := fmt.Sprintf("H%d", row)

		// 現在のセルの値を取得
		cellValue, err := f.GetCellValue(sheetName, cellDate)
		if err != nil {
			fmt.Println("セルの値を取得できません:", err)
			return
		}

		// 空セルを見つけたら書き込む
		if cellValue == "" {

			// 値を書き込む
			// 日付
			if err := formatCell(f, sheetName, cellDate, xlsxDate); err != nil {
				fmt.Println(err)
				return
			}
			// 内容
			if err := formatCell(f, sheetName, cellContent, "打ち合わせ中飲食代（"+numPpl+"名）"); err != nil {
				fmt.Println(err)
				return
			}
			// 接待費
			if err := formatCell(f, sheetName, cellEntertainEx, price); err != nil {
				fmt.Println(err)
				return
			}

			break
		}
		row++
	}

	// ファイルを保存
	if err := f.SaveAs(xlsxFile); err != nil {
		log.Fatal(err)
	}
}
```

## 3. 上記を.pdf圧縮プロジェクトに組み込む

.pdfからOCR処理用画像ファイル生成される為、画像ファイルの保存先から**元のファイル名を含む画像ファイルを検索**し、該当ファイルとして読み取りテキストを取得します

日付に関しては領収書.pdfファイル名の冒頭が一律YYYYMMDD_、現金立替分用.xlsxファイル上の記述はYYYY/M/D、と形式が異なる為、OCR処理で取得した文字列を適宜調整しています

```golang:process_pdf.go
// ファイル名リネーム
// ファイル名冒頭がYYYYMMDD_形式でないreceiptディレクトリ以下のファイル
if !(regexp.MustCompile(`^[0-9]{8}_`).MatchString(fileName)) && strings.Contains(event.Name, "/receipt/") {

    // OCR
    pdfImgDir := "./log/pdf_backups/pdf_img"
    err := GetImg(event.Name, pdfImgDir)
    if err != nil {
        log.Fatalf("画像抽出失敗: %v", err)
    }
    OCRresults, err := RunOCR(pdfImgDir)
    if err != nil {
        log.Fatalf("OCR失敗: %v", err)
    }

    // PDFファイル名（拡張子を除く）
    pdfFileName := strings.TrimSuffix(filepath.Base(event.Name), filepath.Ext(event.Name))

    // pdf_img内のファイルを読み込む
    files, err := os.ReadDir(pdfImgDir)
    if err != nil {
        fmt.Println("ディレクトリの読み込みに失敗:", err)
        return
    }
    // PDFファイル名をファイル名に含む画像ファイルを検索
    for _, file := range files {
        if strings.Contains(file.Name(), pdfFileName) {
            fileName = file.Name()
            break
        }
    }
    ocrTxt := strings.ReplaceAll(OCRresults[fileName], " ", "") //空白削除

    // YYYYMMDD形式の日付を取得
    yyyymmdd, err := ConvertDate(ocrTxt)
    if err != nil {
        fmt.Println("エラー:", err)
        yyyymmdd = ""
    }
    newFileName := filepath.Join(filepath.Dir(event.Name), yyyymmdd+"_"+pdfFileName+".pdf")

    // ファイル名を変更
    if err = os.Rename(event.Name, newFileName); err != nil {
        log.Printf("ファイルのリネームに失敗: %s, エラー: %v", newFileName, err)
        return
    }
    log.Println("ファイルをリネームしました:", newFileName)
    event.Name = newFileName

    // 現金立替分領収書
    // カード支払い分以外に対してのみ実行
    if strings.HasSuffix(filepath.Dir(event.Name), "/receipt") {
        // 金額パターン
        pricePattern := `(?:¥|\d{1,3}(?:,\d{3})*)\s?円`
        strPrice, err := FindPattern(pricePattern, ocrTxt)
        var price int //priceを整数型として宣言
        if err != nil {
            fmt.Println("エラー:", err)
        } else if strPrice != "" {
            // 不要な文字を削除
            strPrice = strings.ReplaceAll(strPrice, "¥", "")
            strPrice = strings.ReplaceAll(strPrice, ",", "")
            strPrice = strings.TrimSpace(strings.ReplaceAll(strPrice, "円", ""))

            // 文字列を整数に変換
            price, err = strconv.Atoi(strPrice)
            if err != nil {
                fmt.Println("変換エラー:", err)
                return
            }
        }
        fmt.Println("取得した金額:", price)

        // 人数パターン
        numPplPattern := `人数\s?\s?[:：]?\s?\d+\s?(名|人)`
        intNumPpl, err := FindPattern(numPplPattern, ocrTxt)
        if err != nil {
            fmt.Println(err)
        }
        // 数字部分だけを抽出する正規表現
        re := regexp.MustCompile(`\d+`)
        numPpl := re.FindString(intNumPpl) //数字部分を抽出

        // Excelファイル更新
        UpdateXlsx(event.Name, yyyymmdd, numPpl, price)
    }
}

// .pdf圧縮処理
// 以下略
```

## 4. ビルド・実行

```bash: bash
cd /Users/{{userName}}/Library/Mobile\ Documents/com~apple~CloudDocs/hiltlinc-sys/check_pdf

go mod tidy
go build -o process_pdf

launchctl unload ~/Library/LaunchAgents/com.user.check_pdf.plist 
launchctl load ~/Library/LaunchAgents/com.user.check_pdf.plist
```