技術記事

DelphiのPDFiumでPDFのオプショナルコンテンツ層を切り替える

図面セットが届き、寸法がそれ専用の層に乗っているとき、それを隠す自明な方法は、そのグループの参照を既定構成の /ON 配列から /OFF へ移すことです。ファイルは保存され、開き直され、そして寸法線はタイトルブロックの上に印刷されたままです。原因は1つ隣のオブジェクト、グループ辞書の中に座っています。/Usage << /View << /ViewState /ON >> >>、レンダラーが構成の配列より先に読む明示的な状態です

層の状態は PDF の中のいくつもの場所に住めますが、/OCProperties への編集が触れるのはそのうちの1つだけです。Google の PDFium エンジンはオプショナルコンテンツを正しく描画しますが、公開 C API には、オプショナルコンテンツグループ(OCG)を列挙し、構成を選び、グループのオンオフを切り替える関数がありません。層を駆動する Delphi コードは、オブジェクトを自分で書き換え、レンダラーの決定順序を正確に再生するしかありません。さもないと、層リストが報告する状態と、ページが見せる状態が食い違います

/ONと/OFFの編集がPDFiumの描画を変えないのはなぜか

/ON と /OFF の配列は5ステップのうちの2番目にすぎず、その後の2ステップが両者を覆せます。ISO 32000-1 §8.11.4.3 は、オプショナルコンテンツ構成辞書を /BaseState、/ON、/OFF、/Locked、/RBGroups、/AS で定義し、§8.11.4.4 は OCG が運び得る Usage 辞書を定義します。仕様の上では、Usage はグループが何のためのものかを記述し、/AS のオート状態エントリがその記述を ON か OFF に変えます。PDFium はもっと短い道を通ります。ある目的のために描画するとき、その目的についてのグループ自身の明示状態が勝つのです。折り合わせると、順序は次のようになります

  • 構成の /BaseState から出発します。ここでは /ON と /Unchanged はどちらも可視と数え、隠すのは /OFF だけです
  • /ON 配列を適用し、次に /OFF 配列を適用します。両方に載ったグループは隠されたままです
  • 要求された目的についてグループの明示的な Usage 状態を適用します。たとえば /Usage << /View << /ViewState /OFF >> >> は、上のすべてに勝ります
  • /Intent が /View も /All も含まないグループは可視として扱います。ビューインテントの可視性に加わらないからです
  • 選択された構成の /AS 配列を最後に走らせます。合致するイベントのエントリが、列挙したグループの状態を設定します
PDFium Component が Delphi のすべての PDF オプショナルコンテンツグループに対して再生する5ステップの可視性決定。BaseState が出発点を定め、構成の ON と OFF の配列が順に適用され、明示的な Usage の ViewState か PrintState のエントリが両方に勝り、Intent 不参加は可視と数え、AS 配列が最後に走る
ON と OFF の配列の編集だけでは足りません。PDFium は BaseState、両方の配列、グループの Usage 状態、そして最後に AS のオート状態規則を1つの判定へ折り込みます。InspectOptionalContent がそれをステップごとに再現します

ステップ3が冒頭のファイルを説明します。作成したすべてのグループに /ViewState /ON を書き込む書き出し元があり、それ以降、配列の編集をどれだけ重ねてもそれらの層は隠れません。印刷と書き出しは同じ規則に従いますが、ねじれが1つあります。レンダラーは /PrintState や /ExportState を先に読み、固有のエントリがなければ /ViewState へフォールバックします。したがって単独の /ViewState /ON は、紙の上でも層を固定します。ステップ4はもっと静かです。/Intent /Design のタグが付いたグループは、ビューインテントの可視性の外にいるため、配列にかかわらず描かれます。一方、/Intent キーを持たないグループは /View として扱われます。仕様が与える既定です

InspectOptionalContentで層と描画状態を一覧する

PDFium Component はこの順序を TPdf.InspectOptionalContent で再生します。メソッドは TPdfOptionalContentInventory を満たし、成功すると True を返します。Usage 引数の既定は ocuView、ConfigurationIndex 引数の既定は -1 で、これはインデックス0を選びます。構成インデックス0が既定の /D 辞書であり、/Configs のエントリはインデックス1からファイル順に続きます。走査の前に、メソッドは PDFium に開いている文書をメモリへ保存させ、オブジェクトストリームを展開するため、セッションの早い段階で行った編集が結果に現れます。/OCProperties のないファイルや、利用可能な範囲の外の構成インデックスは、例外を上げずに、ErrorMessage に理由を入れて False を返します

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  Cfg: TPdfOptionalContentConfiguration;
  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;
  Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
  Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
    [Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
     BoolToStr(Cfg.HasAutomaticState, True)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %-24s visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

各 TPdfOptionalContentGroup は、オブジェクト番号、名前、Intents、そして3つの明示状態 ViewState、PrintState、ExportState を運びます。それぞれ ocsUnspecified、ocsOn、ocsOff のいずれかです。ある状態が ocsOn を読んでいるのにグループが /OFF 配列に座っているとき、それが冒頭の段落の罠です。Locked と RadioGroupIndex は選択された構成から来て、/RBGroups の集合に属さないグループの RadioGroupIndex は -1 です

Usage 辞書の残りは、ファイルが述べる通りの形で報告されます。Language と LanguagePreferred、UserType と UserNames、PageElementSubtype、そして HasZoomRange フラグの後ろの MinimumZoom と MaximumZoom です。EffectiveVisible は、これらのどれにも依存しません。ズーム依存の表示は、ビューアの仕事です。各 TPdfOptionalContentConfiguration は、名前、BaseState、配列の内容、RadioGroups を公開し、さらに OrderGroupObjectNumbers も公開します。これは /Order をグループ参照へフラット化し、ネストしたラベル配列のテキストラベルを飛ばします

印刷と書き出しは画面と食い違い得る

Usage 引数は答えを変えます。そしてそれこそ、この引数が存在する理由です。ocuPrint のもとでは、在庫一覧は /PrintState を、/ViewState へのフォールバック付きで読み、/Event が /Print である /AS エントリだけが加わります。紙の上にだけ現れるように作られた透かし層は、したがって ocuView では隠れて、ocuPrint では可視と報告されます。両方のパスを比較すれば、そうした層は、印刷物が誰かを驚かせる前に見つかります

procedure TFormMain.ReportPrintOnlyLayers;
var
  OnScreen, OnPaper: TPdfOptionalContentInventory;
  I: Integer;
begin
  if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
     not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
    Exit;
  // 両パスとも同じ /OCGs 配列を読むため、グループ順は一致する
  for I := 0 to High(OnScreen.Groups) do
    if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
      Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
        [string(OnScreen.Groups[I].Name),
         BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
         BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;

OCMDの可視性式はどう評価されるのか

オプショナルコンテンツメンバーシップ辞書(OCMD、ISO 32000-1 §8.11.2.2)は、コンテンツの一片を、/P ポリシー経由で、あるいは /And、/Or、/Not でグループを組み合わせる /VE 可視性式経由で、複数のグループに結び付けます。仕様は /P に既定の /AnyOn を与え、/VE が存在するときは /OCGs と /P に優先することにし、グループを持たない OCMD は可視性に影響しないと言います

在庫の Memberships 配列は、同じパスのグループごとの結果に対してこれらの規則を適用するため、要求した用途が通して運ばれます。各メンバーシップは、グループのオブジェクト番号、Policy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff、この4つの外の名前なら ocmpUnknown)、生の VisibilityExpression テキスト、そして自身の EffectiveVisible を報告します。グループのない OCMD は可視と数えます。既知の OCG でないオブジェクト番号への参照も可視と数えるため、1つの宙ぶらりん参照が周囲すべてを隠すことはありません。/VE の評価はネスト深さ32で止まり、それより深いものは隠れとして扱うため、自己参照する式がスタックを溢れさせられません。走査は、/Type /OCMD として間接オブジェクトに保存されたすべての OCMD を列挙します。オブジェクトストリームの中のものも含みます

SaveAsOptionalContentConfiguredで新しい層状態を書く

TPdf.SaveAsOptionalContentConfigured は、GroupObjectNumber と Visible フラグを持つ TPdfOptionalContentStateChange レコードの配列を受け取ります。5ステップの順序のもとで、選択された構成がまさにその状態を生むコピーを書きます。言及しなかったグループは、在庫が計算した可視性を保ちます

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
  ForPrint: Boolean);
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;

  Options := TPdfOptionalContentConfigureOptions.Default;
  if ForPrint then
  begin
    Options.Usage := ocuPrint;         // /Print << /PrintState ... >> を書く
    Options.ConfigurationIndex := 1;   // 最初の /Configs エントリ。/D は元のまま
  end;

  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 セクションとトレーラーを続けます。何かが宛先へ届く前に、結果は厳格なロードポリシーのもとで別の TPdf で開き直され、そのロードが成功しない場合や相互参照テーブルが検証を通らない場合、メソッドは失敗します。ファイルオーバーロードはターゲットの脇の一時ファイルへ書き、検証の後でのみターゲットを置き換えるため、拒否された更新は以前の図面をそのまま残します。これは PDFium ComponentのPDF名前ツリーと番号ツリーエディタが使うのと同じ、検証済みインクリメンタルリビジョンの手法です。印刷プリセットを確認するには、出力を再ロードして InspectOptionalContent(Inv, ocuPrint, 1) を呼んでください。グループは渡した状態を報告するはずです

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

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

構成つき保存は、文書が禁じている変更や、安全に運べない変更を拒み、すべての拒否は宛先が書かれる前に起きます。チェックは固定の順序で走ります。署名された文書は、AllowSignedDocument = True を渡さない限り、何かが起こる前に EPdfError を上げます。暗号化された文書はその次に拒否されます。平文のインクリメンタルオブジェクトは、アクティブなセキュリティハンドラーを運べないからです。それから各要求変更が確認されます。/OCGs にないオブジェクト番号は丸ごと失敗し、構成の /Locked 配列に載ったグループへの変更は失敗します。ただし現在値の再宣言は通ります。最後に、EnforceRadioGroups が有効なとき、可視メンバーを2つ以上残すことになる /RBGroups 集合は拒否されます。署名のものを除くすべての拒否は、理由を Report.ErrorMessage に入れて False を返します

PDFium Component の SaveAsOptionalContentConfigured が、構成つきの Delphi PDF を書く前に適用する拒否ゲート。OCGs の外のオブジェクト番号は失敗し、ロックされたグループは失敗し、可視メンバーを2つ以上持つ RBGroups 集合は拒否され、暗号化文書は平文のインクリメンタルオブジェクトを運べず、署名ファイルは AllowSignedDocument を要求する
すべての拒否は宛先に触れる前に起きます。理由は Report.ErrorMessage に着地し、ファイルが署名されていれば EPdfError のメッセージに着地します

そのゲートのうち2つは、仕様が厳密に要求するところより先へ行きます。§8.11.4.3 は /Locked を、ビューアのユーザーインターフェースを通じた変更に対する保護として記述し、ラジオボタングループを、1つがオンになると他のメンバーがオフになる仕組みとして記述します。PDFium Component は両方をプログラム的な変更にも適用し、ラジオグループについては、どのメンバーをオフにするか選ぶ代わりに拒否します。相互排他の集合の2つのメンバーを求められたライブラリには、どちらの意味だったか分からず、推測はリクエストと矛盾します。他のメンバーを同じ Changes 配列でオフにすれば、保存は通ります

署名文書のゲートが存在するのは、追加されるリビジョンが PDFium の完全な再保存、つまり新しいバイト列の上に座るからです。既存の署名がカバーするバイト範囲が生き延びることは期待できません。その署名を失ってよいときにだけ、AllowSignedDocument = True を渡してください

バッチジョブでは、3つの副作用が重要です。書き換えは選択された構成を /BaseState /ON へ正規化します。作者の /Unchanged や /OFF のベースラインは、同じ結果の可視性を持つ明示的な配列になります。/AS の削除は、紙の上にだけ現れる透かし層のような印刷専用のトリックも取り除きます。それらの規則を保ち、そのイベントについては要求した状態を覆し得ることを受け入れるなら、DisableAutomaticState を False にしてください。そして /ON と /OFF の配列は、Options.Usage に対して計算された状態から書かれるため、構成0へ保存された印刷プリセットは、Usage 辞書を持たないすべてのグループについて、画面の見え方も変えます。印刷プリセットは /Configs エントリに置き、/D は画面に任せてください。コンプライアンス側では、PDF/A-2(ISO 19005-2 clause 6.9)と PDF/UA(ISO 14289-1 clause 7.10)はどちらも構成辞書の中の /AS を禁じます。したがって既定の出力は、PDFium ComponentでのPDF/Aプリフライト検証が、そうでなければ報告していた指摘を1つ消します

Delphiビューアの中で層の変更を適用する

ビューアでは、層のコントロールは、在庫から組み立てたチェックリストであり、その後でメモリへの保存と再ロードが続きます。チェックリストは Groups から満たし、Locked フラグの立ったエントリは無効化し、同じ RadioGroupIndex を共有するメンバーは相互排他として扱い、適用のときはストリームオーバーロードを通して TMemoryStream へ書きます。TPdf.LoadDocument は非アクティブなコンポーネントを期待するため、先にオフへ切り替えてください。バッファードロードはバイトをコピーするので、ストリームをすぐに解放できます

procedure TFormMain.ApplyLayerChecklist(
  const Changes: TPdfOptionalContentStateChanges);
var
  Report: TPdfOptionalContentConfigureReport;
  Output: TMemoryStream;
  KeepPage: Integer;
begin
  Output := TMemoryStream.Create;
  try
    try
      if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
        TPdfOptionalContentConfigureOptions.Default, Report) then
      begin
        ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
        Exit;
      end;
    except
      on E: EPdfError do
      begin
        ShowMessage(E.Message);       // 署名ファイル: Output はまだ空
        Exit;
      end;
    end;
    KeepPage := Pdf.PageNumber;
    Pdf.Active := False;
    Pdf.LoadDocument(Output);         // Buffered の既定は True
    Pdf.PageNumber := KeepPage;
  finally
    Output.Free;
  end;
end;

再ロードの後、PDFium は新しい構成から描くため、画面と次の InspectOptionalContent 呼び出しは一致します。TPdf と TPdfView の結線は、DelphiのPDFium VCLで機能豊富なPDFビューアを作るで扱っています。層をまだ持たない文書に層を追加する必要があるときは、DelphiでOCGとOCMDによりPDF層を作るが SaveAsOptionalContentCreated を歩いています

価格、トライアルビルド、コンポーネントの他の文書機能は、PDFium Component for Delphi 製品ページに載っています