技術記事

DelphiのPDF DLL、ActiveX、dylibバインディング

PDFライブラリが自分のホーム言語を離れた瞬間に現れる問題があります。Windows上のC#からは完璧に動作するバインディングを持っているとしましょう。macOSのPythonから同じ呼び出しが必要になったので、Windowsの宣言ファイルをコピーし、バイナリ名を差し替えて実行します。シンボルはすべて解決されます。ところが最初の呼び出しはゴミを返し、2回目はアクセス違反でクラッシュしますが、PDF関連のコードは何ひとつ変えていません。原因はPDFより1つ下の層にあります。Windowsのエクスポートは呼び出し規約としてStdcallを使い、macOSのdylibは同じ関数を先頭にアンダースコアを付けたCdeclとしてエクスポートしています。どちらかの詳細を取り違えた外部関数宣言は、ドキュメントを1つも開かないうちにスタックを破壊します

この種の失敗はすべて、あらかじめ理解しておく価値のある1つの設計判断に由来します。losLabがDelphiおよびC++Builder向けに提供するソース同梱のPDFエンジンであるPDF Library for Delphiは、オブジェクトモデル全体をTPDFlibという1つのフラットなファサードクラスで包み、そのファサードを3つのバイナリ形態で出荷しています。およそ1,250個の関数をエクスポートするWindows DLL、COM/ActiveXのオートメーションオブジェクト、そしてmacOS向けdylibです。PDFとしての意味論は3つとも同一です。実際に手を焼かせるのは、その下にあるABIの部分です。呼び出し規約、文字列のエンコーディング、ハンドルの所有権、そしてどちら側がどのバッファを解放してよいのか、という点です

1つのファサード、3つのバイナリ形態

TPDFlibのすべての公開関数には、DLにメソッド名を続けた名前のフラットな対応物があります。LoadFromFileDLLoadFromFileに、EncryptDLEncryptに、NewSignProcessFromFileDLNewSignProcessFromFileになります。ほぼすべてのエクスポートの第1引数はDLCreateLibraryが返すInstanceIDで、Delphiの呼び出し側であれば保持しているはずのオブジェクト参照の代わりを務めます。この対応関係は早い段階で身につけてください。つまり、DelphiのAPIリファレンスがそのまま他のあらゆる言語向けのドキュメントを兼ねるということです。クラスにできることはDLLでも予測可能な名前で行え、Pascalのメソッドシグネチャを読めばPythonやC#から必要な呼び出しが分かります

WindowsビルドはPDFlibDLL32.dllPDFlibDLL64.dllを生成します。ホストプロセスのビット数に合うほうを選んでください。64ビットのJavaや.NETのプロセスは、宣言をどう書こうと32ビットのライブラリをロードできないからです

1つのTPDFlibファサードがStdcallのWindows DLL、SafecallのActiveXオートメーションオブジェクト、CdeclのmacOS dylibとして公開される様子を示すアーキテクチャ図
3つのバイナリはいずれも1つのフラットなPDFファサードを共有しますが、呼び出し規約、文字列の扱い、登録の要否が異なります

Windows:StdcallインスタンスとW/A関数のペア

文字列を受け取るエクスポートは、それぞれ2つずつ存在します。ワイド版はPWideChar(UTF-16で、.NET、Java、Pythonのc_wchar_pに自然に対応します)を取り、A接尾辞の版はPAnsiCharを取ります。両者の意味論は同一で、違いはエンコーディングだけです。まさにそれが、両者を混ぜてしまったときの原因究明を苦痛にします。例外は飛ばず、エラーコードも返らず、単にメタデータが文字化けするか、素のASCIIを超える文字を含むパスに対して見当違いの「ファイルが見つかりません」が返るだけだからです。チームがこの形で最初のエンコーディングバグに当たると、たいてい半日を失います。症状はデータを指しているのに、原因は宣言側にあるからです

// Windowsバインディング(PDFlibDLL64.dll):Stdcall、エクスポート名はそのまま
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// macOSバインディング:同じ関数をCdeclで、しかもエクスポート名の先頭にアンダースコア
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

ホストごとに文字幅を1つ選び、それをバインディング生成器に明文化してください。実用的な指針はこうです。ホスト言語がネイティブのUTF-16文字列を持つなら、どこでもW版をバインドし、Aファミリーには二度と触れないことです

macOS:同じ名前、異なるABI

dylibは同じDL関数群を、2つの体系的な変更を加えてエクスポートします。呼び出し規約がStdcallではなくCdeclであること、そしてすべてのエクスポート名の先頭にアンダースコアが付くこと(_DLCreateLibrary_DLLoadFromFileなど)です。どちらの変更も純粋に機械的なので、生成されたバインディングには理想的ですが、Windows用ファイルを手で編集したコピーには危険です。可能なら正規の関数リストを1つだけ保ち、そこからプラットフォームごとの宣言を出力してください。それを怠ると、このページの冒頭で述べたとおりのスタック破壊が起こり、しかもCIで動かす機会が最も少ないプラットフォームでしか再現しません

COMとActiveXのホスト:SafecallとOlevariantのペイロード

VB.NET、C#、VBScript、そして旧来のオートメーションホスト向けには、OCXビルドが同じファサードをIDispatchオートメーションオブジェクトIPDFlibraryとして包み、すべてのメソッドをSafecallで宣言します。この規約はエラーの届き方を変えます。Safecallは内部の失敗をCOMのHRESULTに変換するため、フラットなDLLであれば呼び出し側が確認し忘れかねない整数を静かに返していた場面で、C#の呼び出し側は例外を受け取ります。同じ操作でも、どちらのバイナリをロードしたかによって失敗の作法が2通りに分かれます

バイナリデータには、COM固有の第2の規則が適用されます。オートメーションインターフェイスにはポインタ引数が一切ありません。入力する画像バイト列であれ、出力されるPDFバイト列であれ、バイナリはすべてAddImageFromVariantAppendToVariantといったメソッドを通じてOlevariantとして境界を越えます。バイト配列をバリアントにマーシャリングするのは.NETでは1行です。同じプロセスなのだからという理屈で生ポインタを渡そうとすると、ディスパッチ層は呼び出しを拒否するか壊してしまいます。もう1つ、配置作業でつまずきがちな登録の詳細があります。COMの登録はビット数ごとに独立しているため、32ビットのregsvr32で登録したOCXは64ビットのホストからは見えません。この不一致は、あなたの手を離れてずっと後になってから、顧客のマシン上であの悪名高く不親切な「クラスが登録されていません」として表面化します

ハンドルの規律:インスタンスがドキュメントを所有する

フラットAPIは整数のハンドルで動きます。DLCreateLibraryはインスタンスを返します。ファイルをロードすると、そのインスタンス内でのドキュメントIDが返ります。署名プロセス、文字列リスト、ダイレクトアクセスファイルもそれぞれ独自の整数ハンドルを返し、いずれも同じインスタンスにスコープされます。ライフサイクルはどのFFIホストから見ても同じで、読みやすさのためにここではPascalで示します:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // ワーカースレッドごとに1インスタンス
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // DocumentIDを返し、失敗時は0
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // インスタンスが所有する全ドキュメントを解放
  end;
end;

この所有関係の木から2つのことが導かれます。DLReleaseLibraryは、インスタンス配下のすべてのドキュメントとプロセスハンドルを一度に破棄するので、厳密に必要なクリーンアップ呼び出しはこれだけです。短いスクリプトならそれで十分です。長時間動き続けるサービスでは、余計な儀式を伴う遅いリークになるので、インスタンスが死ぬまで積み上げるのではなく、使い終わったドキュメントは都度解放してください。インスタンスはスレッド分離の自然な単位でもあります。ワーカースレッドごとに固有のInstanceIDを与え、外部ロックなしにスレッド間で共有してはいけません。1つのTPDFlibオブジェクトを複数スレッドで共有しないのと同じ理由です

返される文字列は借り物であって所有物ではない

DLGetPageTextのようにテキストを返す関数は、ライブラリインスタンスが所有して使い回すバッファを指すPWideCharまたはPAnsiCharを返します。契約はこうです。ただちにコピーすること、決して解放しないこと

借用したDLGetPageTextのポインタをただちにコピーする場合と、ライブラリが元のバッファを再利用するまで保持し続ける場合を対比したPDF Library for Delphiのタイムライン
返される文字ポインタはインスタンスが使い回す記憶域を借りているため、コピーは次のライブラリ呼び出しより前に行う必要があります
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // ライブラリが所有するバッファへのポインタ
  PageText := P;                 // ここでコピーする。後の呼び出しがバッファを再利用しうる
end;

C#では、次のライブラリ呼び出しの前にIntPtrをマネージド文字列へマーシャリングすることを意味します。Pythonのctypesでは、ポインタからワイド文字列をただちに切り出すことを意味します。生ポインタを呼び出しをまたいで保持すれば、あらゆるユニットテストを通過したうえで、本番で2つのリクエストが重なった最初の瞬間に失敗するバグを書いたことになります。2回目の呼び出しが、1回目のコードがまだ読んでいたバッファを再利用したからです。同じ所有権の規則は、DLSetProgressCallbackで登録したコールバックについては逆向きに働きます。ライブラリがコールバックへ渡すポインタは、そのコールバックの本体の中でのみ有効であり、コールバックオブジェクト自身は、インスタンスがまだ呼び出す可能性がある限り生き続けていなければなりません(ガベージコレクションのあるホストではピン留めが必要です)。途中で回収されたデリゲートは、何か月も問題なく動いていた.NETバインディングに現れる「ランダムな」アクセス違反の教科書的な原因です

バインディング自体にスモークテストを組み込み、生成した宣言セットを出荷する前に必ず実行してください。ABIの誤りを露呈しやすいカテゴリから1つずつ呼び出しを試します。規約が正しいことを示すためのDLCreateLibraryのような引数なしの関数、エンコーディングが正しいことを示すために非ASCII文字を含むパスを渡す文字列入力の関数、借用バッファの扱いが正しいことを示す文字列出力の関数、そしてエラーがホストへどう届くかを観察するために意図的に失敗させる操作を1つです。作業は15分ほどで、放っておけば何か月も後に顧客のクラッシュダンプとして届いたはずの呼び出し規約とエンコーディングの不具合を、ここで捕まえられます

呼び出し規約、文字列エンコーディング、借用バッファ、エラーの表面化を網羅する、PDF Library for Delphiのバインディングスモークテスト用プローブの2かける2のグリッド
4つの安価なプローブが、生成された宣言が顧客のマシンに届く前に規約、エンコーディング、所有権の不具合を捕まえます

Pythonのctypesの場合を具体的に

Pythonのctypesは、手書きされているのを最もよく見かけるバインディングであり、クロスプラットフォームの分岐を示すのに好都合です。Windowsでは、ctypesがStdcallを適用するようにctypes.WinDLLでライブラリをロードし、接尾辞なしのW関数をバインドし、すべての文字列引数をc_wchar_pとして宣言します。macOSではCdeclのためにctypes.CDLLでロードし、関数リストは同一のまま、名前は先頭のアンダースコアなしで解決します。ctypesを含むほとんどのFFI層は、macOSでアンダースコアの慣習を自動的に補ってくれますが、そこは何百もの宣言をその上に生成する前に、解決済みの呼び出し1つで確認しておくべき唯一の前提です

バインディング作業に付随する配置上の疑問が2つあり、どちらにも明快な答えがあります。素のDLLに登録は不要です。regsvr32はActiveXビルドにのみ関係し、DLLはファイルコピーで出荷できます。レジストリに一切触れたくないWindowsサービスやコンテナでDLLを選ぶ主な理由がこれです。スレッド安全性は、すでに上で述べた規則、すなわちスレッドごとに1インスタンスに帰着します。インスタンスハンドルは、選択中のドキュメント、レンダリングオプション、抽出設定など、エンジンが追跡する可変状態をすべて保持するため、インスタンスを共有した2つのスレッドは、個々の呼び出しが成功を返していても互いの状態を混ぜ合わせてしまいます

バインディングが堅牢になれば、その先の操作はDelphi向けの記事が詳しく扱っているものそのものです。PDF暗号化の適用と監査既存ドキュメントからのテキストと画像の抽出がそれにあたります

3つの統合レイヤーすべてのバイナリはライブラリに同梱されています。エディションとライセンスについてはPDF Library for Delphiの製品ページをご覧ください