0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-12-10

概要

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

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

手順

  1. OCRライブラリを利用して.pdf記述内容を読み取る
  2. 読み取った内容を適宜.xlsx入力
  3. 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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?