Technical Article

DelphiでのPDFレイヤー:オプショナルコンテンツグループ(OCG)

測量士が敷地計画図を開き、ユーティリティ(設備配管)を表示したまま等高線を非表示にしたいとします。レビュー担当者は、画面上で赤線の注釈を表示し、印刷物からは消去したいと考えます。製品シートは1つのファイルから3つの言語で提供され、読者が表示する言語を選択します。これら3つはすべて同じPDF機能であり、Acrobatでこれらの操作を行うパネルは「レイヤー」と呼ばれます。そのパネルの背景にある機能が「オプショナルコンテンツ」であり、これにより単一のページに複数の独立した視覚レイヤーを保持し、ビューアでオンとオフを切り替えることができます。

オプショナルコンテンツはISO 32000-1の§8.11で規定されています。表示状態の単位はオプショナルコンテンツグループ(OCG)であり、名前を保持する/OCGタイプの辞書です。ページ上のマークされたコンテンツはグループに関連付けられ、ビューアはそのグループを現在表示するかどうかを決定します。関連する構成要素であるオプショナルコンテンツメンバーシップ辞書(OCMD)を使用すると、複数のグループの論理的な組み合わせによって表示状態を決定できますが、日常的なケースは、単一の名前付きグループが単一のレイヤーを表すものです。ドキュメントは、次に説明する1つのカタログエントリ/OCPropertiesを介して、メカニズム全体を結合します。

カタログが保持する必要があるもの

OCG単体では機能しません。ビューアがレイヤーを一覧表示し、その状態を記憶するには、ドキュメントカタログに/OCProperties辞書が必要であり、§8.11.4にはそこに何を入れるかが正確に規定されています。ファイル内のすべてのグループを指定する/OCGs配列と、デフォルトの構成を保持する/Dエントリがあります。デフォルトの構成は、ファイルが最初に開かれたときにリーダーが適用する部分です。どのグループがオンで開始し、どのグループがオフで開始するか、ユーザーによる切り替えを禁止するロックされたエントリはどれか、そして/Order配列を介して、レイヤー名がパネル内でどのように配置されネストされているかを記録します。

実務上の影響として、レイヤーの作成は決して局所的な行為にとどまりません。グループはページ上に描画される必要があり、同時に、以前には存在しなかったカタログレベルの構造に登録される必要があります。PDFlibPasは両方を自動的に行います。グループを作成する最初の呼び出しで、/OCPropertiesエントリをカタログに追加し、デフォルトの構成を設定するため、開発者側での個別管理なしでレイヤーの描画と一覧表示の両方が実行されます。

コンプライアンスモードによって機能が制限される理由

レイヤーコードを実行する前に、ドキュメントの準拠ターゲットによってオプショナルコンテンツがそもそも合法かどうかが決定されます。ISO 19005-1で定義されているアーカイブプロファイルであるPDF/A-1は、§6.1.13で/OCPropertiesエントリを明示的に禁止しています。その理由は、このフォーマットの目的に合致しています。アーカイブファイルは、将来のすべてのリーダーに対して同一にレンダリングされる必要があり、ビューアが視認性を変更できるコンテンツは、外観が固定されていないコンテンツとなるため、仕様が曖昧なアーカイブを許容するよりも、この構成を禁止する道を選びました。ISO 19005-2およびISO 19005-3で定義されているPDF/A-2およびPDF/A-3は、それぞれの§6.9で逆の立場をとり、デフォルトの視認性に関するルールを設けた上でオプショナルコンテンツを許可しています。

この違いはAPIに直接現れます。ドキュメントがPDF/A-1モードの場合、NewOptionalContentGroupはグループの作成を拒否してゼロを返します。リクエストを受け入れると、宣言された準拠性に違反するファイルが生成されるためです。PDF/A-2またはPDF/A-3モード、および通常の制約のないPDFでは、同じ呼び出しが成功し、ゼロ以外のグループIDを返します。したがって、ゼロという結果は単なるエラーではなく、アクティブなコンプライアンスレベルにおいてその機能がサポートされていないことをライブラリが示しているのです。

var
  Pdf: TPDFlib;
  LayerID: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.SetPDFAMode(1);                       // PDF/A-1a: OCProperties forbidden

    LayerID := Pdf.NewOptionalContentGroup('Utilities');
    if LayerID = 0 then
      // refused under PDF/A-1; not a transient error, the mode bans layers
      ShowMessage('Optional content is not available in PDF/A-1 mode.');
  finally
    Pdf.Free;
  end;
end;

レイヤーごとに1つではなく2つの状態

レイヤーは単に表示または非表示であるだけではありません。デフォルトの構成は、画面表示用の状態と、印刷出力用の個別の状態を記録します。これは§8.11.4において、ビューアが表示するものと印刷パイプラインが出力するものを区別しているためです。この2つは意図的に独立しています。ドラフトの透かしを画面上に表示し、紙への出力からは除外できます。また、カットラインレイヤーを画面上で非表示にしつつ、プロッターに出力することができます。これら2つを統合してしまうと、一方が他方に従わざるを得なくなり、機能が提供する制御性を失うことになります。

PDFlibPasは、2つのセッターを介してこのペアを公開しています。SetOptionalContentGroupVisibleはグループIDとフラグ(1で表示、0で非表示)を受け取り、画面上でのデフォルト状態を制御します。SetOptionalContentGroupPrintableはグループIDと、ドキュメントの印刷時にレイヤーを出力するかどうかのフラグを受け取ります。対応するゲッターであるGetOptionalContentGroupVisibleGetOptionalContentGroupPrintableはそれぞれ1または0を返すため、一方から他方を推測するのではなく、レイヤーの画面表示と印刷の配置を個別に読み取ることができます。

ページ上に2つのレイヤーを構築する

レイヤーの作成とコンテンツの配置は、決まった手順に従います。レイヤーのコンテンツを現在のページ上に描画し、その後グループIDを指定してSetContentStreamOptionalを呼び出します。これにより、ページの現在のコンテンツストリームがラップされ、これまでに描画されたすべてがそのグループに属するようになります。この呼び出しはその時点でストリーム上にあるものをすべてキャプチャするため、1つのレイヤーのマークを描画して割り当てを行い、その後にのみ次のレイヤーを開始するというルールを徹底する必要があります。以下の例では、1ページ目にユーティリティ(設備配管)を描画し、2ページ目にレビュー用の赤線を描画し、各レイヤーの画面表示および印刷状態を設定して保存します。

var
  Pdf: TPDFlib;
  FontID, UtilLayer, RedlineLayer: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;                          // unconstrained PDF: layers allowed
    Pdf.SetPageDimensions(595, 842);          // A4 in points
    FontID := Pdf.AddStandardFont(0);         // Helvetica
    Pdf.SelectFont(FontID);

    // Layer 1: utilities, drawn then assigned to its own group
    Pdf.SetTextColor(0.10, 0.30, 0.65);
    Pdf.DrawText(72, 770, 'Utilities: water main, valve chamber');
    UtilLayer := Pdf.NewOptionalContentGroup('Utilities');
    Pdf.SetContentStreamOptional(UtilLayer);
    Pdf.SetOptionalContentGroupVisible(UtilLayer, 1);   // shown on screen
    Pdf.SetOptionalContentGroupPrintable(UtilLayer, 1); // and on paper

    // Layer 2: reviewer redline on a fresh page
    Pdf.InsertPages(2, 1);                     // append one page after page 1
    Pdf.SetTextColor(0.80, 0.10, 0.10);
    Pdf.DrawText(72, 770, 'REVIEW: revise valve spec before issue');
    RedlineLayer := Pdf.NewOptionalContentGroup('Reviewer markup');
    Pdf.SetContentStreamOptional(RedlineLayer);
    Pdf.SetOptionalContentGroupVisible(RedlineLayer, 1);    // visible while reviewing
    Pdf.SetOptionalContentGroupPrintable(RedlineLayer, 0);  // never printed

    Pdf.SaveToFile('SitePlan_Layers.pdf');
  finally
    Pdf.Free;
  end;
end;

注意すべきはレビュー赤線レイヤーのケースです。これは画面上に表示されてレビュー担当者がメモを確認できるようにし、印刷フラグがゼロであるため、同じファイルの印刷物にはレビューテキストが出力されません。この非対称性こそが、2つの状態を分離しておく意義なのです。

構成情報を読み取る

レイヤーの読み取りは、同じ構造を異なる方法で巡回します。ファイルが読み込まれた後、GetOptionalContentConfigCountがドキュメントに保持されている構成辞書の数を報告します。最初のデフォルト構成のIDは1です。構成内において、GetOptionalContentConfigOrderCountはオーダーツリーのエントリ数を示し、インデックスは1から始まります。各エントリについて、GetOptionalContentConfigOrderItemLabelはその表示テキストを返し、GetOptionalContentConfigOrderItemLevelはそのネストの深さを返すため、見出しの下にインデントされたサブレイヤーを持つパネルのアウトラインをそのまま再構築できます。

各エントリにはタイプもあります。GetOptionalContentConfigOrderItemTypeは、実際のオプショナルコンテンツグループと、ツリーのセクションの見出しとしてのみ存在するプレーンテキストラベルを区別します。この区別は重要です。グループごとの状態クエリは、実際のグループに対してのみ意味を持つためです。グループエントリに対して、GetOptionalContentConfigStateは構成がそれをオン、オフ、または変更なしで開始するかを報告し、GetOptionalContentConfigLockedはユーザーによる切り替えが禁止されているかどうかを報告します。以下のループは、レベルごとにインデントを行いながら、各グループの状態とロック状況を伴ってオーダーツリーをレンダリングします。

var
  Pdf: TPDFlib;
  Cfg, Count, I, ItemType, GroupID, Indent: Integer;
  Line: string;
begin
  Pdf := TPDFlib.Create(nil);
  try
    if Pdf.LoadFromFile('SitePlan_Layers.pdf', '') = 0 then Exit;
    if Pdf.GetOptionalContentConfigCount = 0 then Exit;

    Cfg := 1;                                  // the default configuration
    Count := Pdf.GetOptionalContentConfigOrderCount(Cfg);
    for I := 1 to Count do
    begin
      Indent := Pdf.GetOptionalContentConfigOrderItemLevel(Cfg, I);
      Line := StringOfChar(' ', Indent * 2)
              + Pdf.GetOptionalContentConfigOrderItemLabel(Cfg, I);

      ItemType := Pdf.GetOptionalContentConfigOrderItemType(Cfg, I);
      if ItemType = 1 then                     // 1 = optional content group
      begin
        GroupID := Pdf.GetOptionalContentConfigOrderItemID(Cfg, I);
        case Pdf.GetOptionalContentConfigState(Cfg, GroupID) of
          1: Line := Line + '  [on]';
          2: Line := Line + '  [off]';
          3: Line := Line + '  [unchanged]';
        end;
        if Pdf.GetOptionalContentConfigLocked(Cfg, GroupID) = 1 then
          Line := Line + ' (locked)';
      end;
      // ItemType = 2 is a text label heading; it has no per-group state

      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

2つのディテールがこのループを正しく保ちます。オーダーインデックスは1からカウントまでの1ベースであり、ライブラリがツリーを内部的に番号付けする方法と一致しています。また、グループごとの呼び出しはアイテムタイプがグループである場合にのみ実行されます。テキストラベルは名前とレベルを持つ見出しですが、オン、オフ、またはロックの状態をクエリすることはできないためです。このチェックをスキップすると、ラベルに対して存在しない状態を要求することになります。

適用領域

レイヤーはプレゼンテーションメカニズムであるため、エンジンはページを描画するすべてのパスでレイヤーを尊重する必要があります。描画側については、Delphiでのマルチエンジンレンダリングのチュートリアルでカバーしています。また、レイヤー名は作成者向けのテキストであり、読者は構造化されたレイヤーアウトラインの恩恵を受けるため、ドキュメント構造とも交差します。これについては、タグ付きPDFとアクセシビリティ構造に関する記事での取り組みに関連しています。どちらもここで説明したオプショナルコンテンツAPIと連携しており、ページ、テキスト、フォント、準拠性の機能と並んで、Delphi PDF Libraryの一部として提供されています。