技術記事

PDFiumでDelphiからPDFの任意表示レイヤーを切り替える

PDFium Componentは、2つのTPdfメソッドでDelphiからPDFの任意コンテンツレイヤー(OCG)を制御します。InspectOptionalContentはすべてのレイヤーを、PDFiumが実際に描画する可視性と合わせて一覧し、SaveAsOptionalContentConfiguredは選んだレイヤーのオン・オフを反映した検証済みコピーを書き出します。2つ目のメソッドはさらに、放置すればあなたの編集を静かに元に戻してしまうUsageと/ASのルールも無効化します。どちらもTPdfに既に開かれている文書に対して動くため、ビューアの表示と同期を取るべき2つ目のパーサーはありません

この要望が届くのは、たいていCADやGISの現場からです。図面セットには寸法、注釈、タイトルブロックが別々のレイヤーで入っており、サプライヤーへ渡す前に寸法を隠したコピーが欲しい、というものです。PDFiumは任意コンテンツを正しく描画しますが、公開ABIにはOCGを列挙し、構成を選び、レイヤー状態を切り替える関数がありません。そこでオブジェクトレベルに降りて/OCPropertiesを編集し、保存し、再読み込みしても、レイヤーはまだそこにあります。理由はPDFiumの可視性ロジックにあり、バイトを触る前に理解しておく価値があります

/ONと/OFFを編集してもPDFiumの描画が変わらない理由

構成ディクショナリの/ONと/OFF配列を編集するだけでは足りません。PDFiumはOCG自身の/Usageディクショナリ内の明示的な状態にこれらの配列より勝たせるからであり、さらに/ASの自動状態ルールがその両方を上書きし得ます。ISO 32000-1 §8.11.4は構成と使用ディクショナリを別々の機構として記述していますが、PDFiumのレンダラーはそれを1つの判定へ折りたたみ、InspectOptionalContentはこの順序でそれを再現します

  • 構成の/BaseStateから出発する。ここでは/ONと/Unchangedはどちらも可視と数え、隠すのは/OFFだけ
  • 構成の/ON配列を適用し、続いて/OFF配列を適用する。両方に挙がったグループは最終的に隠れる
  • 要求された用途についてグループの明示的なUsage状態を適用する。たとえば/Usage << /View << /ViewState /OFF >> >>のようなもので、ここまでのすべてを上書きする
  • /Intentに/Viewも/Allも含まないグループは可視として扱う。ビューintentの可視性に加担しないためだ
  • 最後に選択された構成の/AS配列を実行する。一致するイベントに対するそのエントリは、列挙されたグループの状態を設定する
PDFium ComponentがDelphiで各PDF任意コンテンツグループに対して再生する5ステップの可視性判定:BaseStateが出発点を決め、構成のONとOFF配列が順に適用され、明示的なUsageのViewStateまたはPrintStateエントリが両方を上書きし、Intent不参加は可視と数え、AS配列が最後に走ります
ONとOFF配列の編集だけでは不十分です。PDFiumはBaseState、両配列、グループのUsage状態、そして最後にAS自動状態ルールを1つの判定へ折りたたみ、InspectOptionalContentがそれをステップごとに再現します

人を焼くのは3つ目のステップです。レイアウトツールが保存したファイルは、全OCGに/ViewState /ONを載せていることがよくあります。そうなるとPDFiumは丹念に編集した/OFF配列を無視します。保存は成功し、ファイルは綺麗に再オープンし、それでもレイヤーは描かれます。PrintとExportについては、OcExplicitUsageStateはまずPrintStateかExportStateを読み、該当エントリがなければViewStateへフォールバックします。単独のViewState /ONが印刷でもレイヤーを釘付けにするわけです。OCMD(§8.11.2.2)を参照するマーク付きコンテンツは、続いてこれらのグループごとの結果に照らして解決されます。/Pポリシー経由で、あるいはあれば/VE可視性式で解決します

PDFiumが実際に表示するレイヤーの一覧を出す方法

TPdf.InspectOptionalContentはTPdfOptionalContentInventoryを返します。そのGroups配列は各OCGのオブジェクト番号、名前、intents、3つのUsage状態、言語、ズーム範囲、Lockedフラグ、ラジオグループインデックス、そして計算済みのEffectiveVisibleを運びます。メソッドはまずPDFiumに現在のメモリ内文書を保存させ、オブジェクトストリームを展開し、結果を走査するため、セッション内の先行する編集が反映されます。構成インデックス0は常にデフォルトの/Dディクショナリであり、/Configsのエントリはインデックス1から続きます。デフォルト引数の-1はインデックス0を選びます。/OCPropertiesを持たない文書では、メソッドは例外を投げる代わりにFalseを返し、理由をErrorMessageに入れます

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  G: TPdfOptionalContentGroup;
begin
  // UsageのデフォルトはocuView。-1は構成0、すなわち/Dディクショナリを選ぶ
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Memberships配列はすべてのOCMDを、そのPolicy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff)、生のVisibilityExpressionテキスト、そして自身のEffectiveVisibleと合わせて報告します。いくつかの境界ルールは意図的です。/Pのデフォルトは/AnyOnであり、グループを持たないOCMDは可視と数えます。既知のOCGではないオブジェクト番号への参照は、式全体を失敗させるのではなく可視として扱います。/VEの評価はネスト深さ32で止まり、それより深いものは隠として扱います。敵対的または自己参照する式が、インスペクションをスタックオーバーフローに変えないためです

SaveAsOptionalContentConfiguredで新しいレイヤー状態を書く

TPdf.SaveAsOptionalContentConfiguredはTPdfOptionalContentStateChangeレコード(グループオブジェクト番号とVisible)の配列を受け取り、選択された構成がちょうどその状態を生む文書を書き出します。選択された構成は/BaseState /ONと、全グループを網羅する完全な/ON・/OFF配列を受け取り、既にUsageディクショナリを持つ各OCGは、新しい状態に一致する明示的なViewState(Options.Usageに従ってPrintState・ExportStateの場合もあり)を受け取ります。TPdfOptionalContentConfigureOptions.Defaultでは、選択された構成の/ASキーが取り除かれるため、open、print、exportのイベントがレイヤーを元に戻すことはできません

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  // 構成0、ocuView、DisableAutomaticStateとEnforceRadioGroupsはTrue
  Options := TPdfOptionalContentConfigureOptions.Default;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

書き込み経路は、PDFium自身の保存出力をバイト単位の接頭辞として保ち、書き直された構成オーナーとUsageディクショナリを運ぶOCGオブジェクトだけを追加し、その後に新しいxrefセクションとtrailerを続けます。1バイト目が出力先へ届く前に、結果は別のTPdfで厳格なロードポリシーのもと再オープンされ、相互参照テーブルが検証を通らなければメソッドは失敗します。ファイルオーバーロードはさらに一歩進んでいます。ターゲットの脇に一時ファイルへ書き出し、検証が成功した後にだけターゲットを置き換えるため、拒否された更新が中途半端な図面を残すことはありません。PDFium ComponentのPDF名前ツリー・番号ツリーエディタが使うのと同じ、検証付き増分リビジョンの手法です

PDFium ComponentのSaveAsOptionalContentConfiguredがレイヤーを切り替えたDelphi PDFを書く方法:状態変更とオプションを入れ、選択された構成を完全なON・OFF配列とUsage状態で書き直し、検証済み増分リビジョンを追加し、何かを書く前に厳格な再オープンが検証を通らなければなりません
構成付き保存はPDFium自身の再保存をバイト接頭辞として保ち、書き直された構成オーナーと新しいxrefセクションを追加し、出力先に触れる前に結果を別のTPdfで再オープンします

構成付き保存が拒否すること

構成付き保存は、文書自身が禁じている変更や安全に表現できない変更を拒否し、その拒否はすべて出力先に触れる前に起きます。/OCGsにないオブジェクト番号は問答無用で失敗します。構成の/Locked配列に挙がっているグループの変更は失敗しますが、現在値の再宣言は許されます。EnforceRadioGroupsが有効なとき、可視メンバーが2つ以上残ることになる/RBGroupsのセットは、他を黙ってオフにする代わりに拒否されます。暗号化文書は、平文の増分オブジェクトがアクティブなセキュリティハンドラを運べないため拒否されます。署名付き文書はAllowSignedDocument = Trueを渡さない限りEPdfErrorを投げます。ページの見た目を変えることは、署名カバレッジや認証ポリシーを壊し得るからです

PDFium Componentが構成付きDelphi PDFを書く前にSaveAsOptionalContentConfiguredが適用する拒否ゲート:OCGsの外のオブジェクト番号は失敗、ロックされたグループは失敗、可視メンバーが2つ以上のRBGroupsセットは拒否、暗号化文書は平文の増分オブジェクトを運べず、署名付きファイルはAllowSignedDocumentを要求します
拒否はすべて出力先に触れる前に起き、失敗の理由はReport.ErrorMessageに入ります。中途半端な図面を残すことはありません
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // /Print << /PrintState ... >> を書き出す
  Options.ConfigurationIndex := 1;    // /Configsの最初のエントリ。/Dではない
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocumentはFalseのまま
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // 署名付きファイル:Targetには何も書き込まれない
      Result := False;
    end;
  end;
end;

バッチジョブに組み込む前に、トレードオフを知っておきましょう。追加されるリビジョンは、元のファイルバイトではなくPDFiumのフル再保存の上に載ります。署名付き入力が明示的な同意を必要とするのはまさにそのためです。書き直しは選択された構成を/BaseState /ONへ正規化するため、作者の/Unchangedや/OFFのベースラインは、同じ結果の可視性を持つ明示的な配列に置き換わります。/ASの削除は、紙のときだけ現れる透かしレイヤーのような印刷専用の仕掛けを取り除きます。これらのルールを残したいならDisableAutomaticStateをFalseにします。ただしそのイベントでは要求した状態が上書きされ得るを受け入れましょう。プラス面では、PDF/A-2(ISO 19005-2 clause 6.9)とPDF/UA(ISO 14289-1 clause 7.10)はどちらも構成ディクショナリ内の/ASを禁じているため、デフォルト出力はPDFium ComponentによるPDF/Aプレフライト検証が報告していたはずの問題を1つ取り除きます

Delphi PDFビューアにおけるレイヤー制御の位置

ビューアでは、レイヤー制御はインベントリと保存結果の再ロードに基づくチェックリストです。チェックリストをGroupsから埋め、Lockedのエントリは無効化し、RadioGroupIndexを共有するメンバーは排他として扱い、適用時にはTMemoryStreamへ書き出してそのストリームをTPdfへ読み込み直せば、ビューが新しい状態を描きます。TPdfとTPdfViewの配線は、DelphiでPDFium VCLを使って機能豊富なPDFビューアを構築するで扱っています。ライセンス、トライアルダウンロード、その他の機能一式は、PDFium Component for Delphiの製品ページにあります