기술 문서

Delphi에서 PDFium으로 PDF 선택 콘텐츠 레이어 토글하기

치수가 별도 레이어에 놓인 도면 세트가 도착했고, 그것들을 숨기는 뻔한 방법은 해당 그룹의 참조를 기본 구성의 /ON 배열에서 /OFF로 옮기는 것입니다. 파일을 저장하고 다시 열어도 치수선은 여전히 제목 블록 위에 찍혀 있습니다. 원인은 한 오브젝트 옆, 그룹 사전 안에 있습니다. /Usage << /View << /ViewState /ON >> >>, 즉 렌더러가 구성 배열보다 먼저 읽는 명시적 상태입니다

레이어의 상태는 PDF 안의 여러 자리에 있을 수 있고, /OCProperties에 대한 편집은 그중 하나만 건드립니다. Google의 PDFium 엔진은 선택 콘텐츠를 올바르게 렌더링하지만, 공개 C API에는 선택 콘텐츠 그룹(OCG)을 열거하거나, 구성을 고르거나, 그룹을 켜고 끄는 함수가 없습니다. 레이어를 구동하는 Delphi 코드는 오브젝트를 직접 다시 쓰고 렌더러의 판정 순서를 정확히 재현해야 합니다. 그렇지 않으면 레이어 목록이 하나의 상태를 보고하는 동안 페이지는 다른 상태를 보여줍니다

/ON과 /OFF를 편집해도 PDFium이 렌더하는 게 왜 안 바뀔까요?

/ON과 /OFF 배열은 다섯 단계 중 두 번째일 뿐이고, 그보다 뒤의 두 단계가 이들을 뒤집을 수 있습니다. 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도 없는 그룹은 보이는 것으로 취급합니다. view-intent 가시성에 참여하지 않기 때문입니다
  • 선택된 구성의 /AS 배열을 마지막에 실행합니다. 일치하는 이벤트의 항목이 자신이 이름 올린 그룹의 상태를 정합니다
PDFium Component가 Delphi에서 모든 PDF 선택 콘텐츠 그룹에 대해 재현하는 5단계 가시성 결정: BaseState가 출발점을 정하고, 구성의 ON과 OFF 배열이 순서대로 적용되며, 명시적 Usage ViewState나 PrintState 항목이 둘 다를 덮어쓰고, Intent 불참은 보이는 것으로 치며, AS 배열이 마지막에 돕니다
ON과 OFF 배열 편집만으로는 부족합니다. PDFium은 BaseState와 두 배열, 그룹 Usage 상태, 마지막으로 AS 자동 상태 규칙을 하나의 판정으로 접어 넣고, InspectOptionalContent가 그것을 단계별로 재현합니다

3단계가 도입부의 파일을 설명합니다. 일부 내보내기는 자신이 만드는 모든 그룹에 /ViewState /ON을 기록하고, 그 순간부터 배열 편집으로는 그 레이어들을 숨길 수 없습니다. 인쇄와 내보내기도 같은 규칙을 따르는데 비틀기가 하나 있습니다. 렌더러는 /PrintState나 /ExportState를 먼저 읽고 그 항목이 없으면 /ViewState로 폴백합니다. 홀로 있는 /ViewState /ON은 따라서 종이 위에서도 레이어를 고정합니다. 4단계는 더 조용합니다. /Intent /Design 태그를 단 그룹은 view-intent 가시성 밖이라 배열과 무관하게 칠해지는 반면, /Intent 키가 없는 그룹은 /View로 취급되는데, 이것이 명세가 주는 기본값입니다

InspectOptionalContent로 레이어와 렌더 상태 나열하기

PDFium Component는 TPdf.InspectOptionalContent에서 그 순서를 재현합니다. 이 메서드는 TPdfOptionalContentInventory를 채우고 성공 시 True를 반환합니다. Usage 매개변수의 기본값은 ocuView이고 ConfigurationIndex 매개변수의 기본값은 -1로 인덱스 0을 고릅니다. 구성 인덱스 0은 기본 /D 사전이고, /Configs의 항목들은 인덱스 1부터 파일 순서로 이어집니다. 스캔 전에 메서드는 PDFium이 열린 문서를 메모리에 저장하게 하고 오브젝트 스트림을 풀어서, 세션에서 이전에 한 편집이 결과에 나타나게 합니다. /OCProperties가 없는 파일이나 범위 밖의 구성 인덱스는 호출이 False를 반환하게 만들고 이유는 ErrorMessage에 들어갑니다. 어느 쪽도 예외는 일으키지 않습니다

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, 그리고 세 명시적 상태 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에서 인벤토리는 /ViewState로 폴백하며 /PrintState를 읽고, /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, 그 밖의 이름은 ocmpUnknown), 원본 VisibilityExpression 텍스트, 자신의 EffectiveVisible을 보고합니다. 그룹 없는 OCMD는 보이는 것으로 칩니다. 알려진 OCG가 아닌 오브젝트 번호에 대한 참조 역시 보이는 것으로 쳐서, 매달린 참조 하나가 주변 전부를 숨기지 않게 합니다. /VE 평가는 중첩 깊이 32에서 멈추고 그보다 깊은 것은 숨김으로 취급하므로, 자기 참조 식이 스택을 넘칠 수 없습니다. 스캔은 간접 오브젝트로 저장된 /Type /OCMD의 모든 OCMD를 나열하며, 오브젝트 스트림 포함입니다

SaveAsOptionalContentConfigured로 새 레이어 상태 쓰기

TPdf.SaveAsOptionalContentConfigured는 TPdfOptionalContentStateChange 레코드 배열을 받습니다. 각 레코드는 GroupObjectNumber와 Visible 플래그를 갖고 있습니다. 다섯 단계 순서 아래에서 선택된 구성이 정확히 그 상태를 내놓는 사본을 쓰며, 언급하지 않은 그룹은 인벤토리가 계산한 가시성을 유지합니다

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에서 다시 엽니다

구성 저장은 무엇을 거부할까요?

구성 저장은 문서가 금하거나 안전하게 담을 수 없는 변경은 무엇이든 거부하며, 모든 거부는 목적지가 쓰이기 전에 일어납니다. 검사는 고정된 순서로 돕니다. 서명된 문서는 다른 무엇보다 먼저 EPdfError를 일으키는데, AllowSignedDocument = True를 넘기지 않았을 때입니다. 암호화된 문서는 그다음에 거부됩니다. 평문 증분 오브젝트는 활성 보안 핸들러를 운반할 수 없기 때문입니다. 그다음 요청된 각 변경이 검사됩니다. /OCGs에 없는 오브젝트 번호는 즉시 실패하고, 구성의 /Locked 배열에 이름 올린 그룹에 대한 변경은 실패하지만 현재 값을 다시 진술하는 것은 통과합니다. 마지막으로 EnforceRadioGroups가 켜져 있으면, 보이는 멤버가 둘 이상 남는 /RBGroups 집합은 거부됩니다. 서명 관련 거부를 제외한 모든 거부는 False를 반환하고 이유는 Report.ErrorMessage에 들어갑니다

PDFium Component의 SaveAsOptionalContentConfigured가 구성된 Delphi PDF를 쓰기 전에 적용하는 거부 관문: OCGs 밖의 오브젝트 번호는 실패, 잠긴 그룹은 실패, 보이는 멤버가 둘 이상인 RBGroups 집합은 거부, 암호화 문서는 평문 증분 오브젝트를 운반할 수 없음, 서명된 파일은 AllowSignedDocument를 요구
모든 거부는 목적지가 건드려지기 전에 일어납니다. 이유는 Report.ErrorMessage에, 서명된 파일일 때는 EPdfError 메시지에 착지합니다

그 관문 가운데 둘은 명세가 엄격히 요구하는 것보다 나아갑니다. §8.11.4.3은 /Locked를 뷰어 사용자 인터페이스를 통한 변경에 대한 보호로 기술하고, 라디오 버튼 그룹을 하나가 켜지면 나머지 멤버를 끄는 것으로 기술합니다. PDFium Component는 둘 다 프로그래밍 방식 변경에도 적용하고, 라디오 그룹에서는 멤버를 골라 끄는 대신 거부합니다. 상호 배타적 집합의 멤버 둘을 요청받으면 라이브러리는 여러분이 어느 쪽을 뜻했는지 알 수 없고, 추측은 요청과 모순됩니다. 같은 Changes 배열에서 나머지들을 끄도록 쓰면 저장은 통과합니다

서명된 문서 관문이 존재하는 이유는 덧붙는 리비전이 PDFium의 전체 재저장, 즉 새 바이트 시퀀스 위에 앉기 때문입니다. 기존 서명이 커버하는 바이트 범위가 살아남는다고 가정할 수 없습니다. 그 서명을 잃어도 괜찮을 때만 AllowSignedDocument = True를 넘기세요

배치 작업에서 중요한 부작용이 셋 있습니다. 재작성은 선택된 구성을 /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 프리플라이트 검증이 그렇지 않으면 보고했을 발견 하나를 미리 지워 줍니다

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 제품 페이지에 정리되어 있습니다