技術記事

PDFium Component: Delphi での Lazarus and Free Pascal viewer integration

Delphi と Lazarus は同じ Object Pascal をコンパイルするが、その表面上の類似性こそが、両者の間でビューアを移植する作業を厄介なものにする。この 2 つのツールチェーンは、PDF の作業にとって重要な 3 か所で分岐する。ネイティブの string 型は、Delphi では UTF-16 だが、LCL アプリケーションでは UTF-8 になる。VCL と LCL は、それぞれ独自のコントロール、ダイアログ、フォームストリーミング形式を持つ、まったく別のビジュアルフレームワークである。そして、Delphi のバイナリは Windows を対象とするが、FPC のバイナリは Linux や macOS を対象にすることもある。これらの違いはどれもコンパイル時には表面化しない。単一のソースツリーから VCL 版と LCL 版の両方を提供する PDFium Component の上に構築されたビューアは、いくつかのユニット名の入れ替えと、わずかな {$IFDEF FPC} ブロックさえあれば、Lazarus でも問題なくコンパイルできてしまう。失敗はもっと後になって、実データと実際のデプロイが、Delphi ビルドが静かに前提としていたことを暴き出したときにやってくる

失われる時間の大半は、この 4 つの前提に起因する。UI 境界でのテキストエンコーディング、フォームを 2 重に保守したくなる誘惑、ネイティブエンジンのバイナリが実行時にどう解決されるか、そして SAPI が姿を消した瞬間にテキスト読み上げがプラットフォームを失う場面である。どれも、事前に来るとわかっていれば安く済むが、知らずに追いかけると高くつく

同じ Pascal でも文字列の中身は異なる

Delphi のネイティブ string は、2009 年以降ずっと UTF-16 である。Lazarus と Free Pascal は、LCL アプリケーションでは既定で UTF-8 になる。このコンポーネントのテキスト向け API は WString 型を通じて UTF-16 で話し、FPC ビルドではこれが WideString の別名になっている。そのため、LCL の UI と PDF エンジンの間をテキストが行き来するあらゆる境界が変換ポイントになる

単純な代入であれば変換は自動的に行われ、たいていのコードはそれを意識する必要すらない。エンコーディングのバグを避けるための習慣は 2 つある。テキストはバイト単位の操作を挟まずそのまま通すこと。検索語をバイトオフセットで切り出すコードは、1 つの Char が 1 つの UTF-16 単位である Delphi では動くが、LCL では複数バイトの UTF-8 を壊してしまう。そして、最初の実行から非 ASCII のデータでテストすること。ドイツ語のファイル名、キリル文字の検索語、文書メタデータ内のアクセント付きの著者名。純粋な ASCII のテストデータは、あらゆるエンコーディングの欠陥を覆い隠してしまう。ASCII は UTF-8 と UTF-16 が 1 文字 1 バイトで一致する唯一の範囲だからである。バグはその間ずっと実在しているが、ASCII がそれを見えなくしているだけであり、あなたが一度も試したことのないファイルをミュンヘンの顧客が開いた瞬間に表面化する

Lazarus における LCL ビューアー UI と PDFium コンポーネント間の UTF-16 と UTF-8 文字列変換境界の図
コンポーネントのテキスト API は WString を通じて UTF-16 を話すため、UTF-8 文字列を持つ LCL UI は境界のたびに変換点に出会います。そしてバイトオフセットのスライスや ASCII 限定のテストデータこそ、エンコーディングバグの隠れ家です

IDE ごとにフォークするのではなく、1 つの条件分岐ブロックに

IFDEF が十数個を超えたあたりから、コードベースは 1 つのリポジトリの中に 2 つのプロジェクトが同居しているように感じられ始め、IDE ごとにフォークしたくなる誘惑にかられる。それは誤った選択である。本当の違いは 1 つの共有宣言ブロックへ折りたたむことができ、フォークすればそれ以降のあらゆるバグ修正のコストが倍になる。条件分岐の層は、これくらい小さく保っておくこと

{$IFDEF FPC}
uses
  LCLType, Forms, Graphics, Controls;

type
  WString = WideString;   // コンポーネントのテキスト API は UTF-16
  TBytes  = array of Byte;
{$ELSE}
uses
  Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}

このブロックより下のコードはすべて、両方の IDE で同一にコンパイルされる。文書の取り扱い、ページ移動、レンダリング呼び出し。TPdfTPdfView は VCL 版と LCL 版で同じ表面を公開しているため、ビューアの大部分はコンパイラの条件分岐を一切目にしない。この状態を保つのは巧妙な小技ではなく、構造上の規律の問題である。PDF まわりの共有ロジックは、フレームワーク固有のダイアログやパネルを一切引き込まないユニットに置く。印刷ダイアログやファイル選択ダイアログのように、プラットフォームごとの流儀を持ち、本当に違いのあるわずかな部分だけを、フレームワークごとに 1 回だけ実装した薄いインターフェースの背後に隠す。こうしておけば、IFDEF ブロックは将来のプラットフォームの分岐が着地してよい唯一の場所になり、コンパイラ指令が 40 個のユニットに散らばることもなくなる

フォームは 2 つのデザイナーではなくコードで組み立てる

フォームストリーミングこそ、デュアル IDE プロジェクトが静かに腐っていく場所である。同じフォームを記述しているはずの .dfm.lfm は、プロパティ 1 つずつずれていき、そもそも同じ形式ですらないため、なぜ 2 つのビルドが違う挙動をするのか誰も差分を取れないまま乖離していく。ビューアを実行時に構築すれば、この問題全体を丸ごと避けられる。コンストラクタの手順は 1 つだけであり、普通のコードとしてバージョン管理され、両プラットフォームで同じように読める

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  Pdf := TPdf.Create(Self);

  PdfView := TPdfView.Create(Self);
  PdfView.Parent := Self;
  PdfView.Align := alClient;
  PdfView.Pdf := Pdf;
  PdfView.FitMode := pfmFitWidth;

  if ParamCount > 0 then
  begin
    Pdf.FileName := ParamStr(1);
    Pdf.Active := True;   // ドキュメントを開く。以降 PageCount が有効
  end;
end;

これらの代入の正確な順序よりも、実際の仕事をこなしている 1 行のほうが重要である。PdfView.Pdf := Pdf がビジュアルコントロールを文書コンポーネントに結び付けており、この時点から先は、PageNumber によるページ移動と FitMode によるフィット挙動が、VCL と LCL のもとで同じように動作する。フレームワークをまたいだ 1 つの癖は、ユーザーからバグ報告が来る前に知っておく価値がある。Zoom を手動で代入すると、両方のフレームワークで FitModepfmNone に戻ってしまう。そのため、ツールバーが「幅に合わせる」を保持すべき設定として扱っている場合、プログラムでズームを操作した後は必ずフィットモードを設定し直さなければならない。さもないと、コードがズームレベルに触れた最初の瞬間に、その設定が静かに効かなくなる

IDE が一度も警告してくれなかったバイナリ

このコンポーネントは PDFium エンジンをラップしており、それはネイティブなプラットフォームバイナリとして提供される。そして、そのバイナリこそが「IDE では動くのに、インストール済みのショートカットからは失敗する」という報告のほぼすべての元凶である。その大半は 3 つの原則に集約される。ビット数は正確に一致しなければならない。32 ビットの実行ファイルは 64 ビットの pdfium ライブラリを読み込めず、OS が返してくるメッセージ(Windows のバージョンによっては「モジュールが見つかりません」)は積極的に誤解を招く。ファイル自体は実行ファイルのすぐ隣に置かれているからである。ライブラリのパスは作業ディレクトリではなく、常に実行ファイルからの相対パスで解決すること。IDE からの起動とシェルからの起動は、まさにこの点で違いが出るため、このバグは開発中には隠れたままになる。そして、最初の文書が開く前に読み込みの失敗を捕まえ、期待されるパスとアーキテクチャを明記して報告すること。「パス <path> に PDFium の 64 ビットバイナリが見つかりません」と書かれたサポートチケットは数分で解決する。「起動時にビューアがクラッシュする」と書かれたものは、1 週間のやり取りに発展する

ついでに、エンジンのバイナリも実行ファイルと一緒にバージョン管理しておくこと。PDFium の更新は速く、アプリケーションだけを更新して古いライブラリをディスク上に残すインストーラーは、オフィスの誰も再現できないクラッシュを生み出す。理由は単純で、オフィスのどのマシンもたまたま組み合わせの揃ったペアを持っているからである。ライブラリは、それを読み込む実行ファイルと同じインストーラー、同じバージョン表示、同じロールバック経路を持つ、ビルド成果物の一部として扱うこと

Lazarus または Delphi PDF ビューアー実行ファイル向け 3 つの PDFium ネイティブバイナリロードルールの図
IDE では動くがインストール版では失敗する報告の大半を、3 つの規則がカバーします。実行ファイルと PDFium ライブラリーは同じビット数を共有すること、ライブラリーパスは作業ディレクトリーではなく実行ファイルから解決されること、そしてロード失敗は期待パスを明示して捕捉されることです

Lazarus IDE でのコンポーネント登録

実行時に構築する方式であれば、デザイン時の登録はまったく必要なく、これは自前で UI をコードから組み立てるビューアにとって最もすっきりした構成である。デザイン時の作業のためにコンポーネントを Lazarus のパレットに置きたい場合は、パッケージをインストールし、専用の登録ユニットである Lib/FPC/PDFiumLaz.lpk 内の PDFiumLazReg に処理を任せること。このユニットは意図的にデザイン時専用としてマークされている。IDE のプロパティエディタのインターフェースを参照しており、それが出荷する実行ファイルにリンクされることは決してあってはならないからである

これを誤ると、アプリケーションが理由もなく IDE のパッケージに依存するようになるという症状が現れ、それは Lazarus を一度もインストールしたことのない最初の顧客のマシン上で、デプロイの失敗として表面化する

Windows を離れた音声とスクリーンリーダー

テキスト読み上げは、クロスプラットフォームの物語が崩れる唯一の機能であり、しかもそれが崩れるのはコンポーネントではなく OS の側である。Windows で通常使われる TTS バックエンドである SAPI は、Windows 上にしか存在しない。Windows を対象にし続ける Lazarus ビルドは、完全な SAPI 出力と、Delphi のオリジナルが持っていたのと同じ NVDA 互換の挙動をそのまま保つため、Windows から Windows への移植ではここで何も失われず、NVDA の利用者は 2 つのビルドを区別できない

Linux や macOS を対象にする場合は話が別である。呼び出せる SAPI が存在しないため、その上位にある読み上げ用の API はそのままに、音声出力だけをネイティブの音声サービスへ配線し直さなければならない。この分離こそが、最初のコミットから音声処理をインターフェースの背後に置いておくべき理由である。読み上げ順序の解析と単語追跡カーソルはプラットフォームに中立であり、そのまま持ち越せる。実際に音を生成する薄い層だけが、プラットフォームごとに変わればよい。アクセシブルなリーダーに関する記事が、この読み上げの仕組みを詳しく扱っている

移植完了を宣言する前のパリティチェックリスト

以下の一連の確認は、実際の回帰を捕まえてきたものであり、失敗が表面化しやすい順におおむね並んでいる。パスに非 ASCII 文字を含む文書を開く。非 ASCII 文字を含む語で検索し、ヒット箇所が正しい位置にハイライトされることを確認する。出荷するすべてのウィジェットセットで、マウスホイールのスクロール、ドラッグによる選択、キーボードでのページ移動を試す。フォーカスの扱いとホイールの挙動は、LCL の中でも最もウィジェットセットに依存しやすい部分だからである。100%、150%、200% の表示スケーリングでレンダリングを確認する。最後に、一度も IDE を入れたことのないマシンで、IDE ビルドではなくインストール済みのビルドを実行する。バイナリの解決を正直に検証できるのは、このテストだけだからである。ほかのすべてが通っていても、これだけが静かに失敗することがある

レンダリングのスループットは 2 つのエディション間で変わらず引き継がれるため、レンダーキャッシュとズーム性能に関する記事のキャッシュ手法は、VCL 版向けに書かれたとおりそのまま LCL 版のビューアにも当てはまる

ここまでの内容は、LCL 版を格下に扱うものではまったくない。中核となる表面はどちらの側でも同一である。TPdfTPdfView、レンダリング、フォーム、テキスト抽出、アクセシビリティ API は、どちらの IDE でコンパイルしたかにかかわらず同じように振る舞う。追いかける価値のある違いはすべて、エディションではなくプラットフォームに紐付いている。SAPI による音声は Windows 専用であり、ダイアログは各フレームワークの流儀に従い、バイナリは読み込まれる先のアーキテクチャと一致していなければならない。エンコーディングの境界、実行時のフォーム構築、バイナリの解決さえ正しく押さえておけば、残りの移植作業は、コンパイラがすでに肩代わりしてくれている機械的な仕事にすぎない

Lazarus PDF ビューアーで TTS 出力をプラットフォーム別エンジンの背後へ移す音声インターフェース分割の PDFium Component 図
読み順分析と単語追跡カーソルはプラットフォーム中立のまま、1 つの薄い音声インターフェースが Windows では SAPI に、Linux と macOS ではネイティブ音声サービスに解決します

ここで説明した VCL 版と LCL 版は、Delphi、C++Builder、Lazarus/FPC 向けに、ソースコードと同一の公開 API を備えたPDFium Componentとしてまとめて提供されている