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?

Acrobatが個人情報を消してくれる!SDKとローカルLLMで作った自動墨消し拡張【後編】

0
Last updated at Posted at 2026-07-08

はじめに

前編では、システムの全体設計とローカルLLMを使ったPII Detection Serviceの実装について書きました。後編では、Acrobat SDK プラグインの実装に踏み込みます。

「プラグインを作る」と聞くと敷居が高く聞こえますが、Acrobat SDKは定型パターンがはっきりしています。まず「お約束の関数」を理解してしまえば、あとは普通のC++開発と変わりません。

システム全体像(後編のスコープ)

後編での説明は黄色の2つの部分です。

Acrobat SDKとは

SDK(Software Development Kit)とは、ソフトウェアを拡張・自動化するための開発キットです。Acrobat SDKには、C++のヘッダーファイルやライブラリ、サンプルコード、APIリファレンスが含まれており、これを使うことでAcrobatそのものを拡張するコードを書くことができます。SDKはWindows版とMac版が提供されており、今回はWindows版を使用しています。

SDKのドキュメントは Acrobat SDK Documentation にあります。

拡張の方法は主に3つです。

JavaScript API

Acrobatは独自のJavaScript実行環境を持っており、AcrobatとそのプラグインのAPIをスクリプトから呼び出せます。スクリプトはPDF内に埋め込む方法と、Acrobatが起動時に読み込む外部ファイルとして置く方法があります。

このJavaScript APIでは、いま開いているPDFドキュメントが Doc というオブジェクトで表現され、ページの追加・削除、テキストの取得、注釈の付与といった操作をそのメソッドとして呼び出します。そして、スクリプトの中では現在のドキュメントを指す Doc オブジェクトが this として渡ってきます。つまり this と書けば「操作対象のPDF」を指すことになり、たとえば this.addAnnot() でアノテーション(注釈)を追加したり、this.getPageNthWord() でページ内の単語の座標を取得したりできます。ブラウザのJavaScriptで document を起点にページを操作するのと、感覚としては近いイメージです。

後述するガイド付きアクション(Action Wizard)で実行するスクリプトも、このJavaScript APIで記述します。

Interapplication Communication(IAC)

Acrobatを別プロセスから外部制御する仕組みです。WindowsではOLE(COM)やDDEを通じて、Visual BasicやC#など外部アプリケーションからAcrobatを操作できます。たとえば、自社の業務システムからAcrobatを呼び出してPDFを印刷・変換するといったシナリオで使われます。Acrobatを「ライブラリ」として外部から制御したい場合の選択肢です。

注意: IACを使ってAcrobatデスクトップ版をサーバーサイドや自動化処理で制御する場合、Adobeのライセンス利用規約に違反する可能性があります。このような用途では、サーバー・自動化用途向けに提供されている Automation License を使用してください。

Plugins

Plugins(プラグイン)とは、Acrobatに後付けで機能を追加するDLLのことです。ANSI C/C++で書かれた「動的リンク拡張」で、メニュー項目の追加・PDF操作APIの直接呼び出し・Acrobat内部のイベントへのフックなど、Acrobat本体と同等の権限でPDFを操作できます。.api という拡張子のDLLをAcrobatの plug_ins フォルダに置くだけで、起動時に自動ロードされます。

Plugin APIは以下のようなレイヤー構造となっています。

Plugin API Overview

レイヤー 役割
Acrobat Viewer(AV) ウィンドウ・UI・メニュー操作
Portable Document(PD) PDFドキュメント・ページ・アノテーション操作
Acrobat Support(AS) メモリ管理・ファイルI/O等のユーティリティ
Cos PDFの低レベルオブジェクト(辞書・ストリーム等)への直接アクセス

各レイヤーのAPIは HFT(Host Function Table) という仕組みで提供されています。HFTは関数ポインタのテーブルで、プラグインはHFTを通じてAcrobatの機能を呼び出したり、逆に自分のHFTを公開して他のプラグインに機能を提供したりできます。APIのプレフィックス(AV / PD / AS)はこのレイヤー構造に対応しています:

プレフィックス 対象 例(今回のプラグインより)
AV AcroView — ウィンドウ・UI操作 AVAppGetActiveDoc() : Acrobatで現在開いているドキュメントを取得
PD PDModel — PDFドキュメント操作 PDDocGetFile() : PDFドキュメントオブジェクトからファイルハンドルを取得
AS AcroSupport — メモリ管理・ファイルI/O等の基盤ユーティリティ ASFileSysDIPathFromPath() : C:\Users\... のようなWindowsパスを、PDF仕様で定義されたプラットフォーム非依存のパス形式(DIPath)に変換

今回プラグインを選んだ理由は、外部のLLMサービス(Ollama / OpenAI)を呼び出す必要があったことです。AcrobatのJavaScript環境には fetch のようなHTTPクライアントがなく、外部のAPIを叩くにはC++側でWindowsネイティブのWinHTTP APIを使うしかありません。加えて、解析対象となる「開いているPDFの本体やその保存パス」を取得する処理も、プラグイン側(PD/ASレイヤーのAPI)で行っています。これらはJavaScriptだけでは完結しないため、プラグインとして実装しました。なお、プラグインとJavaScriptは連携できるので、HTTPクライアントはプラグインに置きつつ、呼び出し口はJavaScriptから触れる形にしています(その設計理由は次のセクションで説明します)。

なぜプラグインとJavaScriptを分けた設計にしたか

プラグインのアーキテクチャを決めるにあたって、大きな選択がありました。HTTP呼び出しをプラグイン側に実装してJavaScriptから呼び出す形にしたのには理由があります。

理由1:ガイド付きアクション(Action Wizard)からの実行

Acrobatにはガイド付きアクションという機能があります。JavaScriptの実行ステップを組み込んだワークフローを作れる機能です。作成したアクションはAcrobatのツールメニューにそのまま並ぶので、ユーザーはメニューから項目を選んでボタンを押すだけで実行できます。コマンドを打ったりファイルを指定したりする必要はなく、いつものAcrobat操作の延長でPII墨消しを呼び出せる——この「メニューから一発でアクセスできる」手軽さが大きな利点です。

理由2:LLMの差し替え自由度

プラグインを再ビルドしなくても、ガイド付きアクション側のJavaScriptでプロバイダーとモデルを引数で指定できるようにすることで、新しいモデルが出てもスクリプトを変えるだけで対応できるようにしました。

まずBasicPluginで感触をつかむ

SDK内には BasicPlugin というサンプルが同梱されています。Acrobatプラグイン開発のHello Worldです。これをビルドしてみましょう。

開発環境:

  • Visual Studio 2022
  • Acrobat DC SDK
  • ターゲット:x64

ビルドすると .api ファイルが生成されます。これをAcrobatの plug_ins フォルダにコピーして再起動すると、Acrobatのメニューに自分のコードが追加されます。64bit版のAcrobat DCでは、フォルダのパスは以下の通りです。

C:\Program Files\Adobe\Acrobat DC\Acrobat\plug_ins

確かに拡張できるのねという感触を得ることが最初の一歩です。ビルド自体に管理者権限は不要ですが、plug_ins フォルダへのコピーには管理者権限が必要です。

メニュー>プラグイン>Acrobat SDK>Basic Pluginを選択すると、ダイアログが表示されるはずです。これで、Acrobatが自分のコードをロードして実行できていることが確認できます。

image.png
image.png
Basic Pluginのメニューからの呼び出し(上図)と、実行後(下図)

プロジェクト構成

BasicPluginでしくみがなんとなくわかったところで、今回のプラグインを作っていきます。以下はVisual Studio 2022のC++プロジェクトの構成です。

PIIAIPlugin/
├── src/
│   ├── AcrobatBridge.cpp  # Acrobatとの接続(お約束関数・メニュー登録・JSブリッジ)
│   ├── Exports.cpp        # getPIIData をDLLエクスポート
│   ├── PiiPlugin.cpp/.h   # オーケストレーター(入力を検証してAPIクライアントを呼ぶ)
│   └── PiiApiClient.cpp/.h  # WinHTTPでPII Detection APIを呼び出す
└── project/PIIAIPlugin.vcxproj  # Visual Studio 2022 プロジェクト

Acrobatプラグインの「お約束」関数

Acrobat SDKのプラグインは、決まった関数群を実装する必要があります。SDKはこれらの関数ポインタを通じてプラグインと対話します。入口は PIHandshake です:

ACCB1 ASBool ACCB2 PIHandshake(Uns32 handshakeVersion, void* handshakeData) {
    if (handshakeVersion != HANDSHAKE_V0200) {
        return false;
    }

    PIHandshakeData_V0200* hsData = static_cast<PIHandshakeData_V0200*>(handshakeData);
    hsData->extensionName = GetExtensionName();  // プラグインの識別名

    // 各フェーズのコールバックを登録する
    hsData->exportHFTsCallback   = /* PluginExportHFTs   */;
    hsData->importReplaceAndRegisterCallback = /* PluginImportReplaceAndRegister */;
    hsData->initCallback         = /* PluginInit   */;
    hsData->unloadCallback       = /* PluginUnload */;

    return true;
}

Acrobatは起動時に .api ファイルをロードして PIHandshake を呼び出します。ここで関数ポインタを渡すことで、以降のライフサイクルをAcrobat側がコントロールします。

PluginInit:メニューへの追加とイベント登録

PluginInit はプラグインの初期化時に呼ばれます。メニューアイテムの追加と、PDF開閉イベントの購読はここで行います:

ACCB1 ASBool ACCB2 PluginInit(void) {
    // Acrobat メニューバーに "Acrobat SDK > Analyze PII" を追加。今回メニューからは呼び出ししないので本当は不要。
    const ASBool menuOk = RegisterMenuItem();

    // PDFが開かれるたびにJSブリッジを初期化するフック
    AVAppRegisterNotification(AVDocDidOpenNSEL, gExtensionID,
                              (void*)OnDocDidOpen, nullptr);

    return menuOk;
}

RegisterMenuItem 内では、AcrobatのAPIでメニューバーを取得し、メニューアイテムを作成してコマンドと結びつけます。メニューがクリックされると、プラグインで実装した処理が直接呼び出される仕組みです。

PluginUnload:クリーンアップ

PluginUnload はAcrobat終了時に呼ばれます。登録したイベントの解除とメニューアイテムの削除を行います:

ACCB1 ASBool ACCB2 PluginUnload(void) {
    AVAppUnregisterNotification(AVDocDidOpenNSEL, gExtensionID,
                                (void*)OnDocDidOpen, nullptr);
    if (g_menuItem != nullptr) {
        AVMenuItemRemove(g_menuItem);
        g_menuItem = nullptr;
    }
    return true;
}

この3つ(PIHandshakePluginInitPluginUnload)がAcrobatプラグインの骨格です。BasicPluginサンプルにも同じ構造が入っています。

JavaScriptブリッジの実装

前述のとおりAcrobatのJavaScript環境でHTTPを直接呼び出せないため、プラグイン側でHTTP処理を担当し、JavaScriptからそれを呼べるようにする必要があります。これを「JavaScriptブリッジ」として実装しました。

仕組み

AcrobatのJavaScript環境には app というグローバルオブジェクトがあります。C++から AFExecuteThisScript というAPIを使ってJavaScriptを実行できるので、これを利用してC++側から app.getPIIData() という関数をJavaScript環境に動的に注入します。この注入を PluginInit とPDFオープン時(OnDocDidOpen)に実行することで、JavaScriptから app.getPIIData() が呼べるようになります。

なぜ「非同期」にしたか

ローカルLLMの推論はページ数によっては数分かかることがあります。もし getPIIData() がHTTPの結果を待って値を返す同期関数だと、その数分間Acrobatのウィンドウが固まってしまいます(実際、WinHTTPの受信タイムアウトも5分に設定しています)。

そこで、呼び出しはすぐ返し、結果は後からコールバックで受け取る非同期方式にしました。注入する getPIIData() は、プロバイダーとモデルを控えてC++コマンドを起動したら、待たずに { status: 'queued', jobId } を返します:

// C++からJSに注入する関数(抜粋・簡略化)
"app.getPIIData = function(doc, provider, model, callback) {"
"  app.__piiProvider = provider || 'openai';"
"  app.__piiModel    = model    || 'gpt-4o-mini';"
"  app.execMenuItem('PII:AnalyzeActivePdf');"          // C++コマンドを同期起動
"  var jobId = app.__piiLastJobId || '';"              // C++が発番したジョブID
"  if (typeof callback === 'function' && jobId) {"
"    app.__piiCallbacks[jobId] = callback;"            // 完了時に呼ぶコールバックを登録
"  }"
"  return { status: 'queued', jobId: jobId };"         // 待たずに即リターン
"};"

C++側のコマンド(AnalyzeActivePdfCommand)は、PDFのパスを取得したらワーカースレッドを起こしてHTTP呼び出しをそこに任せ、ジョブIDだけをJavaScriptに返します。あわせて、完了を監視するアイドルプロシージャを登録します:

// メニューコマンド本体(抜粋・簡略化)
ACCB1 void ACCB2 AnalyzeActivePdfCommand(void*) {
    const std::wstring pdfPath = GetActivePdfPath();       // DIパス→Windowsパス変換込み
    const std::string  jobId   = "pii-" + /* 連番 */;

    // 別スレッドでHTTPを実行(UIスレッドを止めない)
    job->worker = std::thread(RunPiiJob, jobId);
    PublishJobIdToJs(pdDoc, jobId);                        // app.__piiLastJobId にセット

    // 完了をUIスレッド側で拾うためのアイドルプロシージャを登録
    AVAppRegisterIdleProc(g_idleProcCallback, nullptr, 15);
}

ワーカースレッドは PiiApiClientPOST /api/v1/analyze を叩くだけです。このスレッドからAcrobatのAPIには触れません。AcrobatのJavaScript実行やSDK関数はUIスレッドで呼ぶ必要があるためです。そこで、結果はいったんジョブに格納しておき、UIスレッドで回るアイドルプロシージャが完了を検知して、JavaScriptへ通知します:

// アイドル時(UIスレッド)に完了ジョブを拾ってJSに通知(抜粋)
void NotifyJobComplete(const std::string& jobId, const std::string& result, ...) {
    // app.__piiJobs[jobId] = { status:'completed', result: JSON.parse(...) };
    // 登録済みなら app.__piiCallbacks[jobId](payload) を発火
}

呼び出しフロー

JS: app.getPIIData(this, "ollama", "qwen3.5:4b", onDone)
      → execMenuItem('PII:AnalyzeActivePdf')     ← C++コマンドを同期起動
          → AnalyzeActivePdfCommand()
              → PDFパス取得(DIパス→Windowsパス)
              → std::thread でワーカー起動(非同期)
              → jobId を app.__piiLastJobId に返す
              → AVAppRegisterIdleProc で完了監視を登録
      ← 即座に { status:'queued', jobId } が返る(UIは固まらない)

[別スレッド] ワーカー : PiiApiClient で POST /api/v1/analyze(数分かかることも)

[UIスレッド] アイドルproc : ジョブ完了を検知
              → app.__piiJobs[jobId] に結果を格納
              → app.__piiCallbacks[jobId](payload)  ← onDone コールバック発火

プラグインとJavaScriptが app グローバルオブジェクトを共有の受け渡し場所として使い、UIスレッドを止めずに非同期に対話する構造です。なお、getPIIData()の進捗バー(サムネイル型のプログレスメーター)を出す関数 getPIIDataWithProgress() も作って同じ仕組みの上に用意しています。

検出結果をRedactionアノテーションに変換する

ここが「後編その2」の中心です。実は addAnnot({ type: "Redact" }) を呼んでいるのは、ガイド付きアクション側のスクリプトではなく、プラグインが注入したJavaScriptルーチンです。ブリッジ注入時(EnsureAppJsBridge)に、getPIIData などと一緒に変換用の app.__piiApplyAllRedacts も仕込んでおき、解析完了時に先ほどの NotifyJobComplete から呼び出します。

中身は、ページ内の単語を1文字ずつつないだストリームを作り、PII文字列にマッチした箇所の矩形(quads)を集めて addAnnot する、というものです(空白の入り方でマッチが切れないよう、正規化してから突き合わせています):

// プラグインが注入し、解析完了時に実行するルーチン(抜粋)
app.__piiApplyAllRedacts = function (doc, items) {
    function norm(s) { return String(s).replace(/\s+/g, ""); }  // 空白を無視して突き合わせる
    for (var p = 0; p < doc.numPages; p++) {
        // ページ内の単語を getPageNthWord で1文字ずつ連結し、
        // 各PII文字列 items[k].value を indexOf でこのページから探す
        for (var k = 0; k < items.length; k++) {
            // マッチした単語の getPageNthWordQuads を集めて quads にする
            var quads = /* マッチ箇所の矩形 */;
            if (quads.length) {
                doc.addAnnot({ page: p, type: "Redact", quads: quads, contents: items[k].value });
            }
        }
    }
};

JavaScriptで書かれていますが、このコードはプラグイン(C++)が文字列として抱えて注入・実行しているので、墨消しアノテーションの付与ロジックはプラグイン側で完結しています。呼び出し側(ガイド付きアクション)は結果を待つ必要も、ループを書く必要もありません。

日本語文字列のエスケープ

プラグインからJavaScriptに日本語の文字列を渡すとき、文字コードの問題が発生します。AcrobatのJavaScript環境はUTF-16を期待するため、C++側でUnicode escapeシーケンス(\uXXXX)に変換して渡しています。

WinHTTPでPDF送信

プラグインからPII Detection ServiceへのHTTPはWindowsネイティブの WinHTTP API を使いました。.NETや他のライブラリに依存せず、Acrobatのランタイム環境で動作するのが利点です。

PDFファイルはバイナリのまま multipart/form-data に埋め込んで送ります。タイムアウトは接続・送信は30秒、受信は5分に設定しています。ページ数の多いPDFはLLMの推論が複数回走るため、数分かかることがあるためです:

ガイド付きアクションとの連携

ガイド付きアクションはAcrobatのツールバーから作れる自動処理ワークフローです。JavaScriptの実行ステップを組み込めます。

墨消しへの変換はプラグイン側で完結しているので(前述の app.__piiApplyAllRedacts)、ガイド付きアクションに登録するスクリプトは実質1行です。進捗バー付きで解析する app.getPIIDataWithProgress() を呼ぶだけで、解析からRedactionアノテーションの付与までをプラグインがやってくれます(進捗バーが不要なら app.getPIIData() でも結果は同じです):

// ガイド付きアクションに登録するステップ
app.getPIIDataWithProgress(this, "ollama", "qwen3.5:4b");

ポイントは、この呼び出しが結果を待たないことです。ローカルLLMの解析はページ数によっては数分かかるため、getPIIDataWithProgress() は解析を開始したらすぐに戻り、処理はバックグラウンドで進みます。その間はサムネイル型の進捗バーが表示され、Acrobatの操作もブロックしません。解析が終わった時点で、プラグインが検出結果(pii_items)を受け取り、Redaction(墨消し)アノテーションを付与します。

適用が終わると、検出されたPIIすべてに赤枠のRedactionアノテーションが付きます。あとはAcrobatの「墨消しを適用」操作(またはスクリプトから this.applyRedactions())で確定すれば、墨消しが適用、完了します。

image.png
Acrobatでpersonal information含むPDFを開いた状態

image.png
Action WizardでPII墨消しアクションを実行中

image.png
検出されたPII箇所に赤いRedactionアノテーションが付いた状態

image.png
墨消し適用後の黒塗りPDF

実装でつまずいた点と対処

AcrobatのDIパス問題

PDDocGetFile 経由でPDFのパスを取得すると、Windowsパスではなく DI(Device Independent)パス 形式で返ってきます:

DI形式: /C/Users/yaochi/Documents/sample.pdf
Win形式: C:\Users\yaochi\Documents\sample.pdf

最初は気づかずに fopen が失敗し続けました。変換処理が必要です:

// "/C/Users/..." -> "C:\Users\..." に変換
std::wstring DiPathToWindowsPath(const std::wstring& diPath) {
    if (diPath.size() >= 3 && diPath[0] == L'/' 
        && isalpha(diPath[1]) && diPath[2] == L'/') {
        std::wstring win;
        win += diPath[1];  // ドライブレター
        win += L":\\";
        for (size_t i = 3; i < diPath.size(); ++i) {
            win += (diPath[i] == L'/') ? L'\\' : diPath[i];
        }
        return win;
    }
    return diPath;
}

WinHTTPのタイムアウト4段階

WinHTTP の WinHttpSetTimeouts は(名前解決・接続・送信・受信)の4フェーズを個別に設定します。LLM推論に時間がかかるのは「受信」フェーズです。最後の引数だけ長く設定しないと、推論途中でタイムアウトします。

thinking modelは stream: true 必須

前編でも触れましたが、これはプラグイン側のタイムアウト設定にも影響します。stream: false だとNext.jsサーバーがLLM完了まで応答を保留するため、WinHTTPの受信タイムアウトが先に切れてしまいます。Next.js側でOllamaをストリーミング呼び出しすることで、サーバーが早めにレスポンスを返し、WinHTTPも安定します。

まとめ

2部構成でAcrobat SDK × ローカルLLMによる個人情報自動墨消しの実装を紹介しました。

作ってみて感じたこと:

Acrobat SDKはあまり知られていませんが、ちゃんと使える: Acrobat SDKに含まれているサンプルコードを参考に、PIHandshakePluginInit の定型パターンさえ押さえれば、あとは普通のC++開発です。JavaScriptとC++の橋渡しの仕組みは最初戸惑いましたが、app グローバルを共有するというAcrobat固有の設計に慣れると見通しがよくなります。

ローカルLLMはPII検出においては実用レベルに達している: メールアドレスや電話番号のような構造化PIIは十分な精度で検出できます。日本語の人名はまだ課題がありますが、プロンプトエンジニアリングで改善の余地があります。

プライバシーと精度はトレードオフ: クラウドLLMのほうが精度は高いですが、個人情報をクラウドに送るリスクと天秤にかける必要があります。プロバイダーを切り替えられる設計にしておくことで、用途ごとに選択できます。

Acrobat SDKに興味を持った方は、まずBasicPluginサンプルから試してみてください。「自分のコードがAcrobatのメニューに追加される、機能を呼びだせる」体験は、なかなか新鮮です。

リファレンス

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?