기술 문서

델파이에서 PDF 아웃라인 편집과 페이지 재매핑

200쪽 핸드북에서 7쪽을 빼면 모든 북마크가 어딘가 잘못된 곳에 착지합니다. 고침은 평평한 제목 목록에서 아웃라인을 다시 만드는 것이 아닙니다. PDFiumPas는 TPdfOutlineEditor를 노출합니다. 진짜 아웃라인 트리를 적재하고, 항목을 옮기고 재목표하게 하고, 이어 ApplyPageMap을 돌려 모든 명시적 목적지를 여러분의 페이지 계획을 통해 이동시킵니다

쪽을 지우면 왜 모든 북마크가 깨지는가

아웃라인 항목이 페이지 번호를 저장하지 않기 때문입니다. 페이지 객체에 대한 참조를 저장하는데, 페이지 객체가 바뀌면 그 참조는 옮겨진 페이지를 가리키거나 아예 아무것도 가리키지 않습니다. ISO 32000-1 §12.3.2.2는 명시적 목적지를 첫 요소가 페이지 사전의 간접 참조이고 /Fit이나 /XYZ 같은 fit 이름이 뒤따르는 배열로 정의합니다. 쪽을 지우면 대롱거리는 참조가 남고, 쪽을 재정렬하면 참조는 여전히 유효하지만 이제 다른 장을 기술합니다. PDFiumPas는 적재할 때 그 배열을 페이지 번호로 되돌려 해석하므로, TPdfOutlineItem.PageNumber는 객체 번호가 아니라 공개 TPdf API와 맞는 1 기반 페이지 인덱스를 줍니다. 그것이 추상화의 전부입니다. 여러분의 재매핑 논리가 문서를 분할, 재정렬, 인쇄 조판할 때 이미 만든 페이지 계획과 같은 좌표계에서 일합니다. 그 계획을 만드는 중이라면, 같은 1 기반 관례가 PDF 문서를 여러 파일로 분할n-up 인쇄 조판과 페이지 재정렬을 통해 흐릅니다

아웃라인은 목록이 아니라 이중 연결 트리다

평평한 제목 배열을 단순히 직렬화할 수 없는 이유는 ISO 32000-1 §12.3.3이 모든 아웃라인 항목을 다섯 개의 별도 링크로 연결하기 때문입니다. /Parent, /Prev, /Next, /First, /Last. 따라서 단일 서브트리를 옮기는 것은 옛 부모, 새 부모, 잘라낸 지점과 삽입 지점 양쪽의 이웃 형제들, 옮겨진 노드 자신의 부모 포인터를 재작성합니다. 그중 하나를 틀리면 규격 준수 리더가 잘린 트리를 보여 주거나 루프에 빠집니다. PDFiumPas는 편집 상태를 안정적인 정수 Id를 가진 TPdfOutlineItem 레코드의 깊이 우선 배열로 유지하므로, 서브트리는 연속된 슬라이스이고 형제 체인은 도출되지 손으로 유지되지 않습니다. TPdfOutlineEditor.Move는 그 슬라이스를 들어 올려 요청된 형제 인덱스에서 새 부모 아래 다시 삽입하고, 블록의 루트만 재할당합니다. 그래프를 망가뜨릴 두 옮기기도 거절합니다. 항목을 자기 서브트리 안으로 옮기는 것과, 존재하지 않는 부모를 이름 짓는 것

델파이에서 PDFiumPas 아웃라인 편집. 제 3장을 Part I 밖으로 내어 문서 루트 아래에 놓으면 옮겨진 노드의 /Parent 포인터와 잘라낸 지점과 삽입 지점 주위의 /First와 형제 /Prev, /Next 링크가 재작성된다
한 번의 Move 호출이 들어 올려진 서브트리의 부모 포인터와 잘라낸 지점과 삽입 지점 양쪽의 형제 링크를 재작성합니다

/Count는 왜 부호가 있는가

부호가 크기가 아니라 펼쳐진 상태를 실기 때문입니다. 양의 /Count는 항목이 열려 있고 그 숫자가 현재 보이는 자손의 수라는 것이고, 음의 /Count는 항목이 접혀 있다는 것입니다. PDFiumPas는 자식을 가진 모든 항목에 자손 수를 쓰고 IsOpenFalse일 때 음수로 만들며, 적재할 때 상태를 IsOpen := HasCount and (CountValue > 0)으로 읽어 들입니다. 이것이 아웃라인 작성기의 가장 흔한 수제 버그입니다. 부호 없는 카운트를 발행하고 조용히 전체 트리를 열게 만드는 것

델파이에서 PDFiumPas가 아웃라인 펼침 상태를 인코딩하는 방법. 양의 /Count는 항목이 열려 있고 보이는 자손을 세며, 음의 /Count는 접혔다는 것이고, 부호 없는 카운트는 모든 리더에게 전체 트리를 펼치게 강제한다
/Count의 부호가 펼쳐진 상태이고 크기가 보이는 자손 수입니다. 그래서 부호 없는 카운트는 조용히 전체 트리를 열게 강제합니다
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // 루트의 둘째 자식이 된다
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // 음의 /Count를 쓴다
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget는 명세가 허락하는 두 모양을 모두 처리합니다. DestinationInActionFalse로 넘기면 PDFiumPas는 직접 /Dest 배열을 쓰고, True로 넘기면 ISO 32000-1 §12.6.4.2에 따라 Go-To 액션, /A << /S /GoTo /D [ page ref suffix ] >>를 씁니다. 어느 쪽이든 먼저 항목에서 기존 /Dest/A를 떼어 내 두어 둘이 공존해 어긋날 수 없게 합니다. 접미사는 기본 /Fit이고 PDF 이름으로 시작해야 합니다. 빈 접미사나 잘못된 접미사가 어떤 리더도 파싱할 수 없는 목적지 배열을 만드는 대신 즉시 예외를 던지는 이유입니다

ApplyPageMap은 페이지 계획을 어떻게 소비하는가

ApplyPageMap은 여러분의 페이지 계획이 이미 검증한 바로 그 배열을 받습니다. NewPageNumbers, 옛 페이지 빼기 1로 인덱스되고, 새 1 기반 페이지 번호 또는 그 쪽이 살아남지 못했을 때 0을 담습니다. 항목 배열을 뒤에서 걷므로 서브트리 삭제가 아직 방문하지 않은 인덱스를 무효화하는 일은 없고, RemappedDestinationCountRemovedDanglingItemCount로 무엇을 했는지 보고합니다

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // 원본 문서의 쪽마다 하나의 항목
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == 이 쪽은 버려졌다

  NewPageNumbers[0] := 1;                // 옛 쪽 1 -> 새 쪽 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // 옛 쪽 10 -> 새 쪽 3

  // True: 대롱거리는 서브트리 전체를 삭제. False: 항목을 유지하고 목표를 뗀다
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

DeleteDangling 플래그가 0으로 매핑된 목적지의 정책을 결정하고, 두 분기 모두 일부러 그렇습니다. True에서 PDFiumPas는 항목과 그 서브트리 전체를 지웁니다. 목표가 사라진 아웃라인 노드는 흔히 함께 사라진 장의 머리이기 때문입니다. False에서는 항목이 제목과 계층을 온전히 유지한 채 살아남지만 /Dest/A는 떼어집니다. 검토에서 사람이 재목표할 때 원하는 것입니다. 진짜 잘못된 입력은 여전히 패치되는 대신 요란하게 실패합니다. 음의 항목이나 제공된 맵의 끝을 지나 가리키는 목적지는 IssueKindpoviInvalidPageMap으로 두고 False를 돌려줍니다

델파이에서 PDFiumPas ApplyPageMap이 PDF 북마크를 재지정하는 방법. 옛 페이지 빼기 1로 인덱스된 페이지 맵이 살아남은 목적지를 새 페이지 번호로 보내고, 0으로 매핑되는 항목은 서브트리와 함께 지워지거나 목표를 떼어진다
페이지 맵은 옛 페이지 빼기 1로 인덱스되고, 0 항목은 대롱거리는 서브트리를 지우거나 목표가 떼어진 항목을 남깁니다

불투명 항목과 정직한 트레이드오프

모든 아웃라인 항목이 PDFiumPas가 추론할 수 있는 페이지 번호를 갖는 것은 아닙니다. 세 종류가 손대지 않은 채 통과됩니다. 이름 있는 목적지, /S /GoTo가 아닌 액션, 파일을 만든 무엇이든 추가한 알 수 없는 사전 키. 이들은 PageNumber가 0인 채 적재되고, 항목 안에 원래 바이트를 유지하며, 명시적으로 Retarget을 부르지 않는 한 그대로 다시 쓰입니다

  • 이름 있는 목적지는 문서 이름 트리로의 키입니다. 그래서 올바르게 재매핑한다는 것은 아웃라인 수준에서 추측하는 것이 아니라 트리를 해석하고 목표 항목을 재작성하는 것입니다
  • /URI, /Launch, 또는 JavaScript 액션은 페이지 의미론이 전혀 없고 조용히 Go-To로 변환되어서는 안 됩니다
  • 벤더 특정 키와 구조 목적지는 보존됩니다. 이해하지 못하는 것을 버리는 것이 왕복에서 데이터를 잃는 길이기 때문입니다

비용은 진짜이고 똑똑히 말할 가치가 있습니다. ApplyPageMap은 그 항목들을 완전히 건너뛰므로, 북마크가 전부 이름 있는 목적지를 쓰는 문서는 쪽 삭제를 구조적으로 유효하고 의미론적으로 낡은 아웃라인으로 통과합니다. 그것은 일부러 한 선택입니다 — 검토자가 잡을 수 있는 낡은 링크가 아무도 눈치채지 못하는 자신만만하게 틀린 링크보다 낫습니다. 편집 전에 들어오는 파일을 분류하는 중이라면 PDF 인테이크 검토 워크벤치의 목록 패스가 어느 문서가 그 바구니에 들어가는지 말해 줍니다

저장: 증분 리비전, 그리고 독립적인 재적재

TPdfOutlineEditor.SaveIncremental은 파일을 재작성하는 대신 희소 증분 리비전을 덧붙입니다. 적재된 항목은 정확한 세대를 포함해 원래 간접 객체 참조를 유지하므로 기존 교차 참조는 유효하게 남습니다. 여러분이 추가한 항목만이 리비전 최대 객체 번호의 하나 뒤에서부터 할당되는 새 번호를 그립니다. 카탈로그는 같은 리비전에서 업데이트되고, 소스에 아웃라인이 전혀 없었다면 빠진 /Outlines 항목이 그것에 추가됩니다

쓰기 뒤에 일어나는 것이 복사할 가치가 있는 부분입니다. PDFiumPas는 완전히 독립적인 편집기로 목적지 스트림을 다시 열고 재적재된 트리를 메모리 속 트리와 비교합니다 — 항목 개수, 제목들, 페이지 번호들, 목적지 접미사들, 액션 대 직접 목적지 형태, 스타일들, 펼침 상태, 부모 관계. 어떤 불일치든, 어떤 적재 실패든, 그럴듯해 보이는 파일을 건네주는 대신 목적지 스트림을 비우고 poviVerificationFailure를 돌려줍니다. 암호화된 소스는 앞에서 poviEncryptedInput으로 거절됩니다. 새 제목과 목적지가 /Encrypt 트레일러를 앞으로 복사하는 것으로는 만들 수 없는 문자열 콘텐츠를 만들기 때문입니다

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

아웃라인을 그것이 무엇인지 — 자기 불변식을 가진 연결된 객체 그래프 — 로 다루면, 쪽 삭제는 북마크 재난이 그치고 하나의 메서드 호출에 건네주는 페이지 맵이 됩니다. TPdfOutlineEditor, ApplyPageMap, 검증된 증분 작성기는 PDFiumPas v3.98.0부터 Delphi, C++Builder, Lazarus용으로 실려 나갑니다. 전체 API를 검토하고 평가판을 내려받는 것은 PDFium Delphi Component 제품 페이지에서