概要
下記記事の拡張機能として、特定のディレクトリにアップされた領収書.pdfの記述内容をGolang/OCRライブラリを利用して読み取った上でファイル名等に使用、更に必要に応じて現金立替分報告用.xlsxに入力するところまでを自動化しました
※カード支払い分と現金立替分の領収書.pdfとではアップ先ディレクトリが異なる為、ディレクトリで各.pdfに対する処理を分岐します
手順
- OCRライブラリを利用して.pdf記述内容を読み取る
- 読み取った内容を適宜.xlsx入力
- 1〜2を.pdf監視・圧縮プロジェクト本体に組み込む
1. OCRで.pdf記述内容を読み取る
下記ライブラリをインストールし、OCR処理を関数化
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) {
// 正規表現で日付パターンを抽出
datePattern := regexp.MustCompile(`(\d{4})[年/]\s?(\d{1,2})\s*[月/]\s?(\d{1,2})`)
matches := datePattern.FindStringSubmatch(ocrText)
if len(matches) >= 4 {
year, month, day := matches[1], matches[2], matches[3]
// 月と日を0埋めしてフォーマット
monthInt, _ := strconv.Atoi(month)
dayInt, _ := strconv.Atoi(day)
formattedDate := fmt.Sprintf("%s%02d%02d", year, monthInt, dayInt)
return formattedDate, nil
}
return "", fmt.Errorf("日付パターンが見つかりませんでした")
}
2. Excelizeを利用し.xlsxへの書き込み
下記ライブラリをインストールし、.xlsx更新処理を関数化
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処理で取得した文字列を適宜調整しています
process_pdf.go
// ファイル名リネーム
// ファイル名冒頭がYYYYMMDD_形式でない
if !(regexp.MustCompile(`^[0-9]{8}_`).MatchString(fileName)) {
// 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 := ""
if err != nil {
fmt.Println("エラー:", err)
} else {
yyyymmdd, _ = ConvertDate(ocrTxt) //エラー時は既定値を使用
}
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
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