🙌

C#でPDFをテキスト化したい (Azure AI Document Intelligence (旧 Form Recognizer))

2023/08/14に公開

はじめに

最近 PDF からテキストを抽出する機会が増えてきました…。
Python だと、OSS でいい感じのライブラリがあるのですが .NET 系だと決め手となるようなデファクトスタンダードな OSS のライブラリは無いような気がします…。ということで、ここでは Azure AI Document Intelligence (旧 Form Recognizer) を使って PDF をばらしてみます。これなら行けるはず…!!

試してみよう

何事もとりあえず試してみて感覚をつかむところからなのでやっていきましょう。Form Recognizer のリソースを Azure で作ってエンドポイントと API キーをポータルから取得して控えておきます。

そして分析元の PDF はインボイス制度の説明用の PDF を使ってみましょう。このページにある(令和4年9月) 適格請求書等保存方式(インボイス制度)の手引き をダウンロードしてきました。

PDF が手に入って Azure 側のリソースも出来たので試してみます。今回は特に使うべき Azure AI Document Inteligence のモデルが決まってない時に最初に使ってみることをお勧めされていた一般的なドキュメントモデル を使ってみようと思います。

.NET の SDK はこちらの NuGetから取得できます。丁度 3 日前にリリースされた最新版です。いいね。

コンソールアプリを作って、ユーザーシークレットにエンドポイントと API キーを登録しておきます。

secrets.json
{
  "Endpoint": "https://リソース名.cognitiveservices.azure.com/",
  "ApiKey": "ひみつのキー"
}

そして、先ほど入手した PDF ファイルを invoice.pdf という名前でプロジェクトに登録しておきます。ビルドアクションはコンテンツにして、出力フォルダーにコピーするように変更しておきましょう。

次に NuGet から Azure.AI.FormRecognizer をインストールして、コードを書いてみます。

Program.cs
using Azure;
using Azure.AI.FormRecognizer.DocumentAnalysis;
using Microsoft.Extensions.Configuration;

// 設定ファイルを読み込み
var c = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();

// 設定を読み込み
var endpoint = c["Endpoint"] ?? throw new InvalidOperationException("Endpoint is required.");
var apiKey = c["ApiKey"] ?? throw new InvalidOperationException("ApiKey is required.");

// クライアントを作成
var client = new DocumentAnalysisClient(new Uri(endpoint), new AzureKeyCredential(apiKey));

// ファイルを読み込んで
using var file = File.OpenRead("invoice.pdf");

// 分析開始!!!
Console.WriteLine($"{DateTime.Now}: アップロード開始");
var result = await client.AnalyzeDocumentAsync(WaitUntil.Started,
    "prebuilt-document", // 今回使うモデルの ID
    file);

// ステータスが完了になるまでポーリング
while (!result.HasCompleted)
{
    Console.WriteLine($"{DateTime.Now}: Waiting...");
    await Task.Delay(3000);
    await result.UpdateStatusAsync();
}

// とりあえず終わったのがわかればいいのでメッセージだけ出す
Console.WriteLine($"{DateTime.Now}: 完了!!");

今回のポイントは AnalyzeDocumentAsync メソッドの第一引数で WaitUntil.Started にしているところです。私が見たサンプルだと WaitUntil.Completed を指定していて AnalyzeDocumentAsync が分析が終わるまで結果が返ってこない実装になっていました。
それでもいいケースが多いのでしょうが、とりあえず分析開始したら一度返ってきてもらって、そこから進捗確認をするようにしたいと思ったので、今回のような実装にしました。進捗の確認は UpdateStatusAsync メソッドを呼び出すことで最新の状況が取得されます。完了しているかどうかは HasCompleted プロパティで確認できるので、これが true になるまでループすれば OK です。

サイズとしては 10MB 程度で 52p の PDF を食わせた結果の実行結果は以下のようになりました。

2023/08/14 16:50:19: アップロード開始
2023/08/14 16:50:44: Waiting...
2023/08/14 16:50:47: Waiting...
2023/08/14 16:50:50: Waiting...
2023/08/14 16:50:53: Waiting...
2023/08/14 16:50:56: Waiting...
2023/08/14 16:50:59: Waiting...
2023/08/14 16:51:03: Waiting...
2023/08/14 16:51:06: Waiting...
2023/08/14 16:51:16: 完了!!

アップロードしてからジョブが起動するまで25秒程度で、そこから完了になるまで32秒程度の合計1分ですね。そこそこ現実的な時間で返ってきてます。

ドキュメントの読み取り

とりあえずテキストがきちんと取れているか確認してみましょう。
戻り値の Pages プロパティの Lines プロパティから行のデータがとれます。LineContent プロパティで文字列データが格納されているのでそのまま標準出力に出してみましょう。

以下のコードを先ほどのコードに追加します。

foreach (var page in result.Value.Pages)
{
    Console.WriteLine($"{page.PageNumber} ページ目");
    foreach (var line in page.Lines)
    {
        Console.WriteLine(line.Content);
    }
}

実行すると以下のような結果になりました。

なんか割とちゃんと文字が抽出できていますね。

6 ページ目に以下のような表があるのでそれも見てみましょう。

ページの情報を出力する場所を、こんな感じに書き直してみましょう。

var table = result.Value.Tables.First();
Console.WriteLine($"## {table.BoundingRegions[0].PageNumber} ページ目");
bool isFirst = true;
foreach (var row in table.Cells.Chunk(table.ColumnCount))
{
    foreach (var col in row)
    {
        Console.Write($"|{col.Content}");
    }

    Console.WriteLine("|");
    if (isFirst)
    {
        foreach (var col in row)
        {
            Console.Write($"|{new string('-', col.Content.Length)}");
        }

        Console.WriteLine("|");
    }
    isFirst = false;
}
2023/08/14 18:17:01: アップロード開始
2023/08/14 18:17:24: Waiting...
2023/08/14 18:17:27: Waiting...
2023/08/14 18:17:30: Waiting...
2023/08/14 18:17:33: Waiting...
2023/08/14 18:17:36: Waiting...
2023/08/14 18:17:40: Waiting...
2023/08/14 18:17:43: Waiting...
2023/08/14 18:17:55: 完了!!
## 6 ページ目
|手続が必要な場合|提出する届出書|
|--------|-------|
|次の事項に変更があった場合 ・氏名又は名称 ・(法人のみ)本店又は主たる事務所の所在地|適格請求書発行事業者登録簿の登載事項変 更届出書|
|適格請求書発行事業者の公表事項の公表(変 更)申出書に記載した公表事項に変更があっ た場合|適格請求書発行事業者の公表事項の公表 (変更)申出書|
|登録の取消しを求める場合※1|適格請求書発行事業者の登録の取消しを求 める旨の届出書※2|
|事業を廃止した場合|事業廃止届出書|
|法人が合併により消滅した場合|合併による法人の消滅届出書|
|個人事業者が死亡した場合※1|適格請求書発行事業者の死亡届出書|

いい感じですね。テーブルの部分をそのままここに貼ってみます。

手続が必要な場合 提出する届出書
次の事項に変更があった場合 ・氏名又は名称 ・(法人のみ)本店又は主たる事務所の所在地 適格請求書発行事業者登録簿の登載事項変 更届出書
適格請求書発行事業者の公表事項の公表(変 更)申出書に記載した公表事項に変更があっ た場合 適格請求書発行事業者の公表事項の公表 (変更)申出書
登録の取消しを求める場合※1 適格請求書発行事業者の登録の取消しを求 める旨の届出書※2
事業を廃止した場合 事業廃止届出書
法人が合併により消滅した場合 合併による法人の消滅届出書
個人事業者が死亡した場合※1 適格請求書発行事業者の死亡届出書

結構いい感じに抽出できてますね。

価格

価格はAzure AI Document Intelligence の価格を見ると 500 ページまで無料で、その後は 1,000 ページあたり ¥1,412.551 になっています。1ページ1円ちょっとって感じですね。割と使いやすいかも。

まとめ

ということで、PDF からテキストを抽出する手段として Azure AI Document Inteligence (旧 Form Recognizer) を試してみました。結構ちゃんとテキストが抜けているので、これを元に text-embedding-ada-002 に食わせてベクトル化させることも出来そうです。

C# でやるならこれかな…。

Microsoft (有志)

Discussion