기술 문서

PDFium로 Delphi에서 PDF 레이어(OCG, OCMD) 만들기

PDFium Component는 TPdf.SaveAsOptionalContentCreated를 통해 Delphi에서 새 PDF 레이어를 만듭니다. 한 번의 검증된 저장에서 이 메서드는 optional content 그룹(OCG)과 optional content 멤버십 딕셔너리(OCMD), 그리고 /OC 항목이 새 그룹을 가리키는 Form XObject로 감싼 호출자 제공 드로잉을 더해, 레이어를 토글할 때 PDFium과 다른 뷰어가 그 드로잉을 보이거나 숨기게 합니다

요청은 추상적인 경우가 드뭅니다. 공장 엔지니어링 팀은 같은 도면 시트에 미터와 야드파운드 치수를 놓고 정확히 한 세트만 보이게 하고 싶어 합니다. 인쇄소는 화면에는 보이지만 막본 전에 끌 수 있는 재단선 오버레이를 원합니다. 어느 파일에도 레이어가 아직 없으므로 토글할 것이 없습니다. 기존 레이어 전환은 다른 작업이며, SaveAsOptionalContentConfigured로 기존 PDF optional content 레이어 토글하기가 다룹니다. 이 글은 애초에 레이어를 만드는 것에 관한 것입니다

PDFium으로 페이지 객체에 그냥 /OC를 설정하면 안 되는 이유

PDFium에는 기존 페이지 객체에 optional content를 붙이는 공개 함수가 없습니다. 공개 헤더에는 FPDFPageObj_SetOC나 동등한 것이 없으므로, 평범한 편집 후 FPDFPage_GenerateContent 경로는 게이트된 콘텐츠를 만들 수 없습니다. 게이트는 파일 구조에 써 넣어야 하고, 유일한 질문은 PDFium이 렌더할 때 어느 구조를 존중하느냐입니다

ISO 32000-1 §8.11.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에 덧붙습니다. 기존 페이지 콘텐츠는 파싱되거나 다시 쓰이지 않습니다

PDFium 아래 ISO 32000-1 optional content 게이트 경로: OC BDC와 EMC 사이의 마크드 콘텐츠는 페이지 객체별로 검사되고, /OC 항목을 가진 Form XObject는 칠해질 때 검사되어 PDFium Component가 Delphi에서 쓰는 경로이며, 어노테이션 /OC는 절대 참조되지 않음
PDFium은 마크드 콘텐츠 구역과 XObject 딕셔너리에 대해서는 optional content를 검사하지만 어노테이션에 대해서는 검사하지 않습니다. 그래서 SaveAsOptionalContentCreated는 제공된 드로잉을 Form XObject와 Do 연산자로 게이트합니다

Delphi에서 그룹과 게이트된 폼 기술하기

TPdf.SaveAsOptionalContentCreated는 배열 셋을 받고 보고서를 반환합니다. 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를 공유하는 그룹들은 하나의 /RBGroups 배열에 들어가며, 이것이 뷰어가 하나를 켜면 다른 것들이 꺼진다는 것을 아는 방법입니다. 각 TPdfOptionalContentCreateForm은 게이팅 그룹을 GroupIndex(Groups 배열에 대한 0부터 시작하는 인덱스)로 이름 짓고, 1부터 시작하는 PageNumber, raw 콘텐츠 스트림 바이트, 경계 상자, 선택적 행렬, 선택적 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는 레코드를 0으로 채웁니다
    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는 두 형태를 정의합니다. /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는 거부됩니다. 표현식은 한 단계 깊이이며 만들어진 그룹 위의 poceAll, poceAny, poceNot이고, /Not은 정확히 하나의 그룹 인덱스를 요구합니다. poceAll은 /And로, poceAny는 /Or로, poceNot은 /Not으로 직렬화되는데, §8.11.2.2가 정의하는 연산자는 이 셋뿐입니다. 3.181.2 이전 버전은 PDFium이 숨김으로 평가하는 /All과 /Any를 썼으므로, 오래된 빌드로 OCMD를 만들었다면 표현식을 신뢰하는 대신 다시 생성하십시오. 모든 멤버십은 표현식이 있더라도 MemberGroupIndices에 최소 하나의 항목이 필요합니다. 그 목록이 /OCs 배열이 되기 때문입니다. 새 딕셔너리의 객체 번호는 Report.CreatedMembershipObjectNumbers로 돌아옵니다

Delphi에서 PDFium Component가 만드는 optional content 멤버십 딕셔너리: /AnyOn이나 /AllOn 정책을 가진 /OCs 목록은 그룹 상태를 함께 평가하고, 그룹 위의 /Not 같은 /VE 가시성 표현식은 And, Or, Not 논리로 한 단계 깊이 콘텐츠를 게이트함
정책은 /OCs 목록을 평가하고 /VE 표현식은 그룹을 /And, /Or, /Not으로 결합합니다. 어느 쪽이든 멤버십은 참조할 최소 하나의 멤버 그룹이 필요합니다

OCMD 위에 무언가를 만들기 전에 알아 둘 가치가 있는 동작이 둘 있습니다. 첫째, 같은 호출의 게이트된 폼은 그룹만 참조합니다. TPdfOptionalContentCreateForm.GroupIndex는 Memberships가 아니라 Groups를 인덱스하므로, 폼은 이 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 배열은 뷰어가 보여 주는 레이어 트리를 빚는 중첩 배열과 텍스트 레이블을 담을 수 있는데, 배열을 평탄화해 재구축하던 초기 접근은 그 레이블들을 잃었습니다. 끝에 삽입하면 기존 트리가 온전히 유지되고 새 그룹들이 단지 그 뒤에 나열됩니다. 자체 간접 객체에 사는 배열은 제자리에서 해석되고 패치됩니다

Delphi에서 PDFium Component로 기존 /OCProperties 딕셔너리 확장: 새 optional content 그룹 참조가 닫는 괄호 바로 앞에 삽입되어 중첩 /Order 배열과 텍스트 레이블이 살아남고, 간접 /D 기본 구성은 따라가서 제자리에 패치됨
닫는 괄호 앞에 참조를 덧붙이면 기존 /Order 트리가 온전히 유지됩니다. 중첩 배열과 레이블을 평탄화하는 재구축은 그것을 보장할 수 없습니다

v3.175.0부터 기본 구성 자체도 간접일 수 있습니다. 일부 생산자는 /OCProperties << /OCGs [...] /D 12 0 R >>를 쓰고, PDFium Component는 이제 그 참조를 따라가 잘못된 항목 오류로 실패하는 대신 대상 딕셔너리를 패치합니다. 간접 /XObject 하위 딕셔너리를 포함한 직접 및 간접 페이지 /Resources 딕셔너리도 같은 방식으로 처리됩니다

새 레이어가 정말 토글되는지 증명하기

/OCProperties에는 존재하지만 아무것도 게이트하지 않는 레이어는 만들기 쉽고 알아차리기 어렵습니다. 개발 중 폼 라이터의 첫 빌드는 이미 자체 N 0 obj 헤더와 endobj 트레일러를 갖고 있던 스트림 객체를 두 번째 헤더로 감쌌습니다. 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를 설정하고 출력을 하나도 쓰지 않고 실패합니다:

  • 최소 하나의 그룹이 필요합니다. 그룹, 멤버십, 폼은 각각 4096 항목으로 제한되고, 그룹 이름은 유일하며 1에서 255자여야 하고, 각 폼은 최대 16 MiB의 콘텐츠 바이트를 받습니다
  • 암호화된 문서는 거부되고, 서명된 문서에는 AllowSignedDocument = True가 필요합니다. 출력은 전체 재작성이므로 기존 서명은 더 이상 검증되지 않습니다
  • 모든 Forms 항목은 자기 Form XObject가 되므로, 200페이지에 같은 오버레이를 놓으면 콘텐츠가 한 번이 아니라 200번 저장됩니다
  • 새 Do 스트림은 기존 페이지 콘텐츠 뒤에 덧붙으므로, 앞선 스트림들이 남긴 그래픽스 상태에서 실행됩니다. 잘 형성된 페이지는 상태를 복원하지만, q/Q가 맞지 않는 페이지는 오버레이를 밀어낼 수 있습니다
  • 기존 페이지 콘텐츠는 절대 레이어로 옮겨지지 않습니다. Forms로 넘긴 드로잉만 게이트됩니다

큰 문서에서 저울질할 것은 반복 지점입니다. 오버레이가 무겁고 모든 페이지에 반복된다면, Form XObject 기반 재사용 페이지 스탬프의 공유 객체 접근이 파일에 한 벌만 유지하지만, 레이어 게이트는 직접 처리해야 합니다

FAQ

PDFium Component는 기존 페이지 콘텐츠를 새 레이어에 넣을 수 있나요?

아니요. TPdf.SaveAsOptionalContentCreated는 Forms로 넘긴 콘텐츠만 게이트합니다. 기존 연산자를 마크드 콘텐츠로 감싸거나 XObject로 옮기지 않습니다. 기존 아트워크를 레이어화하려면 자족적인 폼 콘텐츠로 재생성하고 원본 드로잉은 별도 단계에서 제거하십시오

레이어가 꺼져 있어도 게이트된 어노테이션이 계속 보이는 이유는요?

PDFium은 렌더링 경로에서 어노테이션 딕셔너리의 /OC 항목을 검사하지 않으므로, 어노테이션 수준의 게이팅은 PDFium 기반 뷰어에서 효과가 없습니다. PDFium Component가 레이어 콘텐츠를 Form XObject로 붙이는 이유가 바로 이것이며, PDFium은 Form XObject를 게이트합니다

나중에 새 레이어를 전환할 때 어느 객체 번호를 써야 하나요?

Report.CreatedGroupObjectNumbers는 방금 쓰인 파일의 번호를 담습니다. 그 파일을 다시 열어 구성할 때는 TPdf.InspectOptionalContent에서 ObjectNumber를 가져와 Name으로 대조하십시오. 나중 저장이 객체 번호를 다시 매겨도 이름은 올바르게 남습니다

PDFium Component는 SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured, InspectOptionalContent를 PDFium 렌더러 위에 만들어진 하나의 optional content 도구함으로 Delphi와 C++Builder에 제공합니다. 상세와 다운로드는 PDFium Component 제품 페이지에 있습니다