PDFium Component は、TPdf.SaveAsOptionalContentCreated を通じて Delphi から新しい PDF レイヤーを作ります。1 回の検証済み保存で、このメソッドは optional content グループ(OCG)、optional content メンバーシップ辞書(OCMD)、そして /OC エントリが新しいグループを指す Form XObject に包まれた、呼び出し元提供の描画を追加します。レイヤーが切り替えられたとき、PDFium や他のビューアがその描画を表示したり隠したりするためです
要求はめったに抽象的ではありません。プラントエンジニアリングのチームは、同じ図面シートにメートル法とヤード・ポンド法の寸法を、ちょうど 1 セットだけ見える状態で載せたい。印刷屋は、画面では見えて校正の前には切り替えられる裁ち落とし線のオーバーレイが欲しい。どちらのファイルにもまだレイヤーはなく、切り替えるものがありません。既存レイヤーの切り替えは別の仕事で、SaveAsOptionalContentConfigured で既存の PDF optional content レイヤーを切り替えるが扱います。本記事は、まずレイヤーを作るところの話です
PDFium でページオブジェクトに /OC を設定するだけではだめなのか
PDFium には、既存のページオブジェクトに optional content を付ける公開関数がありません。公開ヘッダには FPDFPageObj_SetOC もそれに相当するものもなく、通常の「編集してから FPDFPage_GenerateContent」の経路ではゲート付きコンテンツを作れません。ゲートはファイル構造の中に書き込まれる必要があり、問いは、PDFium が描画するときどの構造を尊重するかだけです
ISO 32000-1 §8.11.3 はグラフィックスを任意にする 3 つのやり方を挙げ、PDFium はそれぞれを違って扱います
/OC /Name BDC…EMCのマーク済みコンテンツで包まれたページコンテンツストリーム区間。描画中にページオブジェクトごとに検査されます- 自分の辞書に
/OCエントリを持つ Form XObject と画像 XObject。XObject が塗られるときに検査されます - 注釈辞書に
/OCエントリを持つ注釈。PDFium の描画経路はこれをまったく参照しないため、ゲート付きの注釈はレイヤー状態にかかわらず見えたままです
マーク済みコンテンツの包み込みは既存のコンテンツストリームを演算子ごとに書き直すことを意味し、注釈はレンダラによって退けられます。残るのは Form XObject の経路で、PDFium Component が使うのはこれです。描画は /OC がグループを指す新しい Form XObject に入り、フォームはページの /Resources /XObject 辞書に登録され、単一の /Name Do を持つ新しいコンテンツストリームがページの /Contents に追記されます。既存のページコンテンツがパースされたり書き直されたりすることはありません
Delphi でグループとゲート付きフォームを記述する
TPdf.SaveAsOptionalContentCreated は 3 つの配列を取り、レポートを返します。TStream に書くオーバーロードとファイル名に書くオーバーロードの両方が同じパラメータリストを共有します。Groups: TPdfOptionalContentCreateGroups、Memberships: TPdfOptionalContentCreateMemberships、Forms: TPdfOptionalContentCreateForms、out Report: TPdfOptionalContentCreateReport、任意の AllowSignedDocument: Boolean = False です。このメソッドは PDFium Component v3.154.0 で登場しました
各 TPdfOptionalContentCreateGroup は Name、任意の Creator、pociView か pociDesign の Intent、InitialStateVisible フラグ、Locked フラグ、RadioTag を持ちます。同じ RadioTag を共有するグループは 1 つの /RBGroups 配列に入り、ビューアはこれで、1 つをオンにすると他がオフになることを知ります。各 TPdfOptionalContentCreateForm は、GroupIndex(Groups 配列への 0 起点インデックス)でゲートのグループを名指しし、1 起点の PageNumber、生のコンテンツストリームバイト、バウンディングボックス、任意の行列、任意の ResourceName を持ちます
var
Pdf: TPdf;
Groups: TPdfOptionalContentCreateGroups;
Forms: TPdfOptionalContentCreateForms;
Report: TPdfOptionalContentCreateReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'sheet-a1.pdf';
Pdf.Active := True;
SetLength(Groups, 2); // SetLength がレコードをゼロ埋めする
Groups[0].Name := 'Dimensions (mm)';
Groups[0].Intent := pociView;
Groups[0].InitialStateVisible := True;
Groups[0].RadioTag := 'Units';
Groups[1].Name := 'Dimensions (in)';
Groups[1].Intent := pociView;
Groups[1].InitialStateVisible := False;
Groups[1].RadioTag := 'Units';
SetLength(Forms, 2);
Forms[0].GroupIndex := 0;
Forms[0].PageNumber := 1;
Forms[0].Content := TEncoding.ASCII.GetBytes(
'0 0 1 RG 1.5 w 72 120 m 540 120 l S');
Forms[0].BBoxRight := 612;
Forms[0].BBoxTop := 792;
Forms[0].ResourceName := 'DimMetric';
Forms[1] := Forms[0];
Forms[1].GroupIndex := 1;
Forms[1].Content := TEncoding.ASCII.GetBytes(
'1 0 0 RG 1.5 w 72 140 m 540 140 l S');
Forms[1].ResourceName := 'DimImperial';
if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
Groups, nil, Forms, Report) then
raise Exception.Create(Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
フォームのコンテンツは純粋なベクター描画であることに注意してください。コンポーネントは各フォームに空の /Resources 辞書しか書かないため、コンテンツストリームは自己完結でなければなりません。デバイス色、パス、クリッピング、トランスフォームは機能しますが、名前付きリソースを要するもの(Tf のためのフォント、Do のための画像、gs のための ExtGState)には解決先がありません。ResourceName を空のままにすると、フォームは FmOc1、FmOc2 と名付けられます。カスタム名には文字、数字、アンダースコア、マイナス、ピリオドだけが使えます
OCMD が素のグループに足すもの
optional content メンバーシップ辞書は、可視性を複数のグループに同時に依存させます。ISO 32000-1 §8.11.2.2 は 2 つの形式を定義します。/AnyOn や /AllOn のような /P ポリシー付きの /OCs リストと、仕様の /And、/Or、/Not から組み立てられる /VE 可視性式です。PDFium Component は MemberGroupIndices、Policy、Expression を持つ TPdfOptionalContentCreateMembership を取ります。Expression.GroupIndices が空でなければ、式が勝り、/VE が /OCs と並んで書かれます
procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
SetLength(Memberships, 2);
// どちらかの単位レイヤーがオンのとき可視:/OCs [...] /P /AnyOn
SetLength(Memberships[0].MemberGroupIndices, 2);
Memberships[0].MemberGroupIndices[0] := 0;
Memberships[0].MemberGroupIndices[1] := 1;
Memberships[0].Policy := ocmpAnyOn;
// ヤード・ポンド法レイヤーがオフの間だけ可視:/VE [ /Not ref ]
SetLength(Memberships[1].MemberGroupIndices, 1);
Memberships[1].MemberGroupIndices[0] := 1;
Memberships[1].Expression.Kind := poceNot;
SetLength(Memberships[1].Expression.GroupIndices, 1);
Memberships[1].Expression.GroupIndices[0] := 1;
end;
対応サブセットは意図的に狭いです。ポリシーは ocmpAllOn と ocmpAnyOn に限られ、ocmpAnyOff と ocmpAllOff は拒否されます。式は 1 レベルの深さで、作成済みグループの上の poceAll、poceAny、poceNot であり、/Not は正確に 1 つのグループインデックスを要します。poceAll は /And に、poceAny は /Or に、poceNot は /Not にシリアライズされます。§8.11.2.2 が定義する 3 つの演算子だけです。3.181.2 より前のバージョンは /All と /Any を書いており、PDFium はそれを非表示と評価します。古いビルドで OCMD を作ったなら、式を信頼するのではなく再生成してください。すべてのメンバーシップは、式があっても MemberGroupIndices に最低 1 エントリを要します。そのリストが /OCs 配列になるからです。新しい辞書のオブジェクト番号は Report.CreatedMembershipObjectNumbers で返ります
OCMD の上に構築する前に知っておくべき振る舞いが 2 つあります。まず、同じ呼び出しのゲート付きフォームが参照するのはグループだけです。TPdfOptionalContentCreateForm.GroupIndex のインデックス対象は Groups であって Memberships ではないため、フォームをこの API で新規作成の OCMD でゲートすることはできません。次に、PDFium Component は新しい OCMD をそれぞれカタログの /OCProperties /OCGs 配列へ加えますが、/ON、/OFF、/Order はグループ専用のままです。ISO 32000-1 §8.11.4.2 は /OCGs をグループのリストと述べますが、その登録がなければ TPdf.InspectOptionalContent はメンバーシップをまったく列挙しません。副作用として、出力のインベントリは各 OCMD を Inventory.Groups の余分な名前なしエントリとして列挙するため、グループは位置ではなく Name で照合してください
コンポーネントは既存の /OCProperties をどうパッチするのか
TPdf.SaveAsOptionalContentCreated は、/OCProperties をゼロから組み立てるか、カタログにすでにあるものを拡張します。そして拡張は追記によって行い、再構築はしません。レイヤーのない文書には、/OCGs と、/ON、/OFF、/Order を運ぶ既定の構成 /D、そして要求するグループがあれば /Locked と /RBGroups を持つ新しい辞書が与えられます。ISO 32000-1 §8.11.4.3 の配置どおりです
すでにレイヤーを持つ文書では、すべてのターゲット配列は、閉じ括弧の直前に新しい参照を挿入することでパッチされます。ひどく細かい話に聞こえますが、理由があります。/Order 配列は、ビューアが表示するレイヤーツリーを形作るネストした配列とテキストラベルを保持でき、配列を平らにして再構築する以前のアプローチはそれらのラベルを失いました。末尾への挿入は既存のツリーを無傷のまま保ち、新しいグループをその後ろに列挙するだけです。独自の間接オブジェクトに住む配列は、その場で解決されパッチされます
v3.175.0 から、既定の構成そのものも間接であり得ます。一部のプロデューサは /OCProperties << /OCGs [...] /D 12 0 R >> と書くため、PDFium Component は今、その参照を追跡してターゲット辞書をパッチし、不正エントリのエラーで失敗する代わりに動きます。間接の /XObject サブ辞書を含む、直接と間接のページ /Resources 辞書も同じやり方で扱われます
新しいレイヤーが本当に切り替わることの証明
/OCProperties には存在するが何もゲートしないレイヤーは、作るのは簡単で気付くのは難しいものです。開発中、フォームライターの最初のビルドは、独自の N 0 obj ヘッダーと endobj トレーラーをすでに運ぶストリームオブジェクトを、2 番目のヘッダーの中に包みました。PDFium は二重になったオブジェクトをパースできず、エラーを報告する代わりに Do 演算子を静かにスキップしました。ファイルは開き、レイヤーパネルは正しく見え、ゲートされた描画は決して現れませんでした。それを露呈させたのは、レイヤーを切り替えて描画ピクセルを比較するプローブです。差がなかったので、どこかが壊れていました
だからこそメソッドは、提供の前に検証します。出力は、クロスリファレンステーブルを検査する厳密なロードを通って開き直され、要求されたすべてのグループが InspectOptionalContent を通じて読み戻され、その名前、初期状態、ロック登録が確認されます。その後でのみ出力はあなたのストリームへコピーされ、ファイルオーバーロードは一時ファイルに書いてターゲットを原子的に置き換えます。同じ切り替えチェックは自分でも走らせられます。出力を開き直し、名前でグループを見つけ、逆の状態でコピーを保存するのです
function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
Pdf: TPdf;
Inventory: TPdfOptionalContentInventory;
Changes: TPdfOptionalContentStateChanges;
Options: TPdfOptionalContentConfigureOptions;
CfgReport: TPdfOptionalContentConfigureReport;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourceName;
Pdf.Active := True;
if not Pdf.InspectOptionalContent(Inventory) then
raise Exception.Create(Inventory.ErrorMessage);
for I := 0 to High(Inventory.Groups) do
if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
(Inventory.Groups[I].Name = 'Dimensions (in)') then
begin
SetLength(Changes, Length(Changes) + 1);
Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
end;
Options := TPdfOptionalContentConfigureOptions.Default;
Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
Options, CfgReport);
finally
Pdf.Free;
end;
end;
両ファイルの 1 ページ目を描画すれば、青と赤の線が入れ替わるはずです。入れ替わらないなら、まずインベントリでグループの EffectiveVisible 値を検査してください。構成配列と使用規則が適用された後、PDFium が実際に描画する状態がそれです
計画に織り込むべき限界と罠
TPdf.SaveAsOptionalContentCreated は限られたプロファイルであり、驚きのほとんどは、汎用のレイヤーエディタとして扱うことから来ます。下の制約は前もって強制されるため、違反する呼び出しは Report.ErrorMessage を設定して失敗し、出力は何も書きません
- 最低 1 つのグループが要ります。グループ、メンバーシップ、フォームはそれぞれ 4096 エントリまで、グループ名は一意で 1〜255 文字、各フォームは最大 16 MiB のコンテンツバイトを受け付けます
- 暗号化された文書は拒否され、署名済み文書には
AllowSignedDocument = Trueが要ります。出力は完全な書き直しなので、既存の署名はもう検証を通りません Formsの各エントリは専用の Form XObject になるため、同じオーバーレイを 200 ページに置くと、コンテンツは 1 回ではなく 200 回格納されます- 新しい
Doストリームは既存のページコンテンツの後に追記されるため、先行するストリームが残したグラフィックス状態で走ります。形の整ったページは状態を復元しますが、q/Qが不均衡なページはオーバーレイをずらし得ます - 既存のページコンテンツがレイヤーへ移動されることはありません。ゲートされるのは
Formsで渡した描画だけです
大きな文書で重さを量るべきは繰り返しの点です。オーバーレイが重くてすべてのページに現れるなら、Form XObject 上に構築された再利用可能なページスタンプの共有オブジェクト方式が、レイヤーゲートを自分で扱うコストと引き換えに、ファイルに 1 つのコピーを保ちます
FAQ
PDFium Component は既存のページコンテンツを新しいレイヤーに入れられるか
いいえ。TPdf.SaveAsOptionalContentCreated がゲートするのは Forms で供給したコンテンツだけです。既存の演算子をマーク済みコンテンツに包んだり XObject へ移したりはしません。既存のアートワークをレイヤー化するには、自己完結したフォームコンテンツとして再生成し、元の描画は別のステップで取り除いてください
レイヤーがオフでもゲートした注釈が見えたままの理由
PDFium は描画経路で注釈辞書の /OC エントリを検査しないため、注釈レベルのゲートは PDFium ベースのビューアでは効果がありません。だからこそ PDFium Component は、レイヤーコンテンツを、PDFium がゲートする Form XObject 経由で付けます
後で新しいレイヤーを切り替えるときはどのオブジェクト番号を使うべきか
Report.CreatedGroupObjectNumbers は、今書かれたファイルの中の番号を保持します。そのファイルを開き直して構成するときは、TPdf.InspectOptionalContent から ObjectNumber を取り、Name で照合してください。後の保存がオブジェクトを再番号付けしても正しくあり続けます
PDFium Component は、PDFium レンダラの上に構築された、Delphi と C++Builder 向けの 1 つの optional content ツールキットとして SaveAsOptionalContentCreated、SaveAsOptionalContentConfigured、InspectOptionalContent を出荷します。詳細とダウンロードは PDFium Component 製品ページにあります