Technical Article

Delphi의 PDF 레이어: Optional Content Groups (OCG)

설계사가 대지 평면도를 열어 설비 라인은 유지한 채 등고선만 숨기고자 할 수 있습니다. 검토자는 화면에서는 적색 표시 주석을 보면서 인쇄물에서는 제외하고 싶어 합니다. 제품 설명서가 하나의 파일에서 세 가지 언어로 제공되고, 독자가 표시할 언어를 선택하기도 합니다. 이 세 가지 시나리오는 모두 동일한 PDF 기능을 사용하며, Acrobat에서 이를 제어하는 패널을 '레이어'라고 부릅니다. 이 패널의 이면에 있는 기능이 바로 optional content이며, 단일 페이지에 뷰어가 켜고 끌 수 있는 여러 독립적인 시각적 레이어(strata)를 배치할 수 있도록 해 줍니다.

Optional content는 ISO 32000-1 §8.11. 표시 여부의 단위는 이름을 가지는 /OCG 유형의 딕셔너리인 optional content group(OCG)입니다. 페이지의 마크업된 콘텐츠(marked content)는 특정 그룹과 연관되며, 뷰어는 해당 그룹의 현재 표시 여부를 결정합니다. 관련 구조인 optional content membership dictionary(OCMD)를 사용하면 여러 그룹의 부울 조합에 따라 표시 여부를 결정할 수 있지만, 일반적인 경우에는 단일 레이어를 나타내는 단일 명명 그룹이 사용됩니다. 문서는 다음에 설명할 하나의 카탈로그 항목인 /OCProperties를 통해 전체 메커니즘을 함께 연결합니다.

카탈로그에 포함되어야 하는 내용

OCG 자체만으로는 아무 동작도 하지 않습니다. 뷰어가 레이어를 나열하고 그 상태를 기억하려면 문서 카탈로그에 /OCProperties 딕셔너리가 필요하며, §8.11.4에서 여기에 포함될 내용을 정확히 정의하고 있습니다. 파일의 모든 그룹을 명명하는 /OCGs 배열이 있으며, 기본 구성을 유지하는 /D 항목이 존재합니다. 기본 구성은 파일이 처음 열릴 때 리더가 적용하는 영역입니다. 어떤 그룹이 활성화 상태로 시작하고 어떤 그룹이 비활성화 상태로 시작하는지, 사용자가 토글하지 못하도록 잠긴 항목은 무엇인지, 그리고 /Order 배열을 통해 패널에서 레이어 이름이 어떻게 배열되고 중첩되는지를 기록합니다.

실질적인 결론은 레이어를 생성하는 것이 단순히 페이지 로컬 작업으로 끝나지 않는다는 점입니다. 페이지 내에서 그룹을 그려야 할 뿐만 아니라 기존에 존재하지 않던 카탈로그 레벨 구조에 등록해야 합니다. PDFlibPas는 이 두 가지 작업을 자동으로 처리합니다. 그룹을 생성하는 첫 번째 호출이 카탈로그에 /OCProperties 항목을 추가하고 기본 구성을 초기화하므로, 개발자가 별도로 관리하지 않아도 레이어가 렌더링되고 레이어 패널 목록에 표시됩니다.

표준 준수 모드에서 이 기능을 제공하지 않을 수 있는 이유

레이어 관련 코드가 실행되기 전에, 문서의 규정 준수 대상(conformance target)에 따라 optional content of 심지어 합법적인지 여부가 결정됩니다. PDF/A-1, ISO 19005-1에 정의된 아카이브 프로파일은 §6.1.13에서 /OCProperties 항목을 엄격히 금지합니다. 이는 포맷의 목적에 부합하는 조치입니다. 아카이브 파일은 먼 미래에도 모든 리더에서 동일하게 렌더링되어야 하며, 가시성을 변경할 수 있는 콘텐츠는 모양이 고정되지 않은 것으로 간주되므로 모호한 아카이브를 방지하기 위해 이 구조를 금지하는 것입니다. 반면 ISO 19005-2 및 ISO 19005-3에 정의된 PDF/A-2 및 PDF/A-3은 §6.9에서 정반대의 관점을 취하여 기본 가시성에 대한 규칙과 함께 optional content를 허용합니다.

이러한 차이는 API에 즉시 반영됩니다. 문서가 PDF/A-1 모드일 때 NewOptionalContentGroup은 그룹 생성을 거부하고 0을 반환합니다. 요청을 수락하면 선언된 자체 규정을 위반하는 파일이 생성되기 때문입니다. PDF/A-2 또는 PDF/A-3 모드와 일반적인 비제한 PDF 모드에서는 동일한 호출이 성공하여 0이 아닌 그룹 ID를 반환합니다. 따라서 0이 반환되는 것은 단순한 일시적 오류가 아니라, 라이브러리가 현재 활성화된 준수 수준(compliance level)에서 해당 기능을 지원하지 않음을 알리는 것입니다.

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;

레이어당 하나의 상태가 아닌 두 개의 상태

레이어는 단순히 보이거나 보이지 않는 것에 그치지 않습니다. 기본 구성은 화면 표시 상태와 별도의 인쇄 상태를 기록하는데, 이는 §8.11.4가 뷰어에 표시되는 것과 인쇄 파이프라인에서 출력되는 것을 구분하기 때문입니다. 두 상태는 의도적으로 독립되어 있습니다. 초안 워터마크는 화면에는 표시하고 종이 인쇄물에서는 제외할 수 있으며, 재단선 레이어는 화면에서는 숨기면서 플로터(plotter)로 보낼 수 있습니다. 이 두 가지를 하나로 병합하면 한쪽이 다른 쪽을 강제로 따르게 되어 레이어 기능 고유의 제어력을 잃게 됩니다.

PDFlibPas는 두 개의 세터(setter)를 통해 이 쌍을 제공합니다. SetOptionalContentGroupVisible은 그룹 ID와 플래그(1은 보임, 0은 숨김)를 받아 기본 화면 표시 상태를 관리합니다. SetOptionalContentGroupPrintable은 그룹 ID와 문서 인쇄 시 레이어 출력 여부를 결정하는 플래그를 인수로 사용합니다. 이에 대응하는 게터(getter)인 GetOptionalContentGroupVisibleGetOptionalContentGroupPrintable은 각각 1 또는 0을 반환하므로, 한쪽 상태를 통해 다른 쪽 상태를 추정할 필요 없이 레이어의 화면 표시 및 인쇄 설정을 개별적으로 읽어올 수 있습니다.

페이지에 두 개의 레이어 만들기

레이어를 생성하고 채우는 작업은 정해진 순서를 따릅니다. 현재 페이지에 레이어용 콘텐츠를 그린 다음, 그룹 ID를 사용하여 SetContentStreamOptional을 호출합니다. 이를 통해 페이지의 현재 콘텐츠 스트림을 래핑하여 지금까지 그린 모든 요소가 해당 그룹에 속하도록 만듭니다. 이 호출은 호출하는 순간 스트림에 있는 모든 콘텐츠를 캡처하므로, 한 레이어의 요소를 배치하고 이를 할당한 후에만 다음 레이어를 시작하는 규칙을 지켜야 합니다. 아래 예제는 첫 번째 페이지에 설비 라인을 배치하고 두 번째 페이지에 검토용 적색 표시를 배치한 다음, 각 레이어의 화면 및 인쇄 상태를 설정하고 저장합니다.

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;

적색 표시(redline) 레이어는 주의해서 볼 만한 사례입니다. 화면에는 표시되어 검토자가 메모를 볼 수 있게 하지만, 인쇄 가능 플래그(printable flag)를 0으로 설정하여 동일한 파일을 인쇄할 때 검토 텍스트가 인쇄되지 않도록 처리합니다. 이러한 비대칭성은 두 상태를 별개로 유지하는 가장 큰 이유입니다.

설정 다시 읽어오기

레이어를 읽는 것은 동일한 구조를 다른 방식으로 분석하는 작업입니다. 파일을 로드한 후, GetOptionalContentConfigCount는 문서에 포함된 설정 딕셔너리의 수를 보고합니다. 첫 번째 기본 설정의 ID는 1입니다. 설정 내부에서 GetOptionalContentConfigOrderCount는 정렬 트리(order tree)의 항목 수를 반환하며, 1부터 인덱싱을 시작합니다. 각 항목에 대해 GetOptionalContentConfigOrderItemLabel은 표시 텍스트를 반환하고, GetOptionalContentConfigOrderItemLevel은 중첩 깊이를 반환하므로 제목 아래에 들여쓰기된 하위 레이어가 있는 패널 레이아웃을 그대로 재구성할 수 있습니다.

각 항목은 유형도 가집니다. GetOptionalContentConfigOrderItemType은 실제 optional content group과 트리의 섹션 제목 역할만 하는 일반 텍스트 레이블을 구분합니다. 그룹별 상태 쿼리는 실제 그룹에 대해서만 유효하기 때문에 이러한 구분이 중요합니다. 그룹 항목의 경우, GetOptionalContentConfigState는 설정이 이를 활성화(on), 비활성화(off)로 시작하는지 또는 변경 없이 유지하는지 보고하며, 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;

두 가지 세부 사항이 이 루프를 정상적으로 작동하게 해 줍니다. 정렬 인덱스는 1부터 시작하는 1-based이며, 라이브러리가 내부적으로 트리에 번호를 매기는 방식과 일치합니다. 또한 그룹별 호출은 항목 유형이 그룹일 때만 실행됩니다. 텍스트 레이블은 이름과 깊이 수준을 가질 뿐 쿼리할 활성, 비활성 또는 잠금 상태가 없는 일반 제목이기 때문입니다. 이 방어 코드를 건너뛰면 상태 정보가 없는 레이블에 상태를 요청하는 오류가 발생합니다.

적용 가능한 영역

레이어는 프레젠테이션 메커니즘이므로 엔진은 페이지를 렌더링하는 모든 경로에서 레이어 설정을 준수해야 합니다. 렌더링 영역은 Delphi의 멀티 엔진 렌더링 가이드에서 다룹니다. 또한 레이어는 문서 구조와도 연관됩니다. 레이어 이름은 작성자에게 노출되는 텍스트이며 독자는 구조화된 레이어 아웃라인을 통해 도움을 얻기 때문입니다. 이는 태그가 지정된 PDF 및 접근성 구조에 대한 문서에서 다루는 내용과 연결됩니다. 두 주제 모두 이 블로그의 다른 곳에서 설명하는 페이지, 텍스트, 글꼴 및 사양 준수 기능과 함께 Delphi용 PDF 라이브러리의 일부로 제공되는 optional content API와 쌍을 이룹니다.