기술 문서

델파이 PDFiumPas로 PDF 사이에 AcroForm 필드 이식

작년 템플릿에서 올해 레이아웃으로 폼 필드 블록을 옮기는 일이 FDF와 XFDF 왕복으로는 부족해지는 지점입니다. 값은 도착하지만 외관 스트림, 계산 액션, 기본 리소스는 도착하지 않습니다. PDFiumPas는 그 경우에 GraftPdfAcroForm으로 답합니다. 한 PDF에서 필드 객체 그래프 전체를 클론해 내어 다른 PDF에 씁니다

데이터 수준 내보내기가 이것을 못 하는 이유는 구조적입니다. 필드는 레코드가 아니라 서브그래프입니다. ISO 32000-1 §12.7은 /Fields, /CO, /DR, /DA를 담은 대화형 폼 사전을 정의하고, §12.7.3은 그 아래 매달린 필드 사전을 정의하며, §12.5.6.19는 그 필드들에게 페이지 위 보이는 상자를 주는 위젯 주석을 정의합니다. XFDF는 그 구조의 잎을 옮깁니다. 이식은 구조 자체를 옮깁니다

/Fields 배열 복사가 결코 충분하지 않은 이유

/Fields를 한 문서에서 다른 문서로 복사하면 흥미로운 방식 전부로 부서진 폼이 나옵니다. 배열은 간접 참조만 담고 그 외엔 아무것도 없기 때문입니다. ISO 32000-1 §7.3.10은 간접 객체를 객체 번호 더하기 세대로 주소화하는데, 그 번호들은 나온 파일 안에서만 의미가 있습니다. 배열을 건너 붙이면 그 안의 모든 참조는 대롱거리거나, 더 나쁘게는 목적지에서 그 슬롯을 차지하다시피 한 무관한 객체로 조용히 해석됩니다. 각 참조 아래에는 공유되고 순환하는 그래프가 앉아 있습니다. 필드 사전은 자기 자식들을 가리키고, 각 자식은 자기 /Parent로 되돌아 가리키며, 위젯은 자기 외관 스트림과 /P로 자기를 실어 나르는 페이지를 가리키고, 외관 스트림은 폼의 기본 리소스 사전의 폰트를 가리키고, /AA 아래 추가 액션 사전은 또 다른 객체들을 가리킵니다. 다른 페이지의 두 위젯은 흔히 하나의 폰트와 하나의 외관 XObject를 공유합니다. 그래서 올바른 이식은 그 그래프를 걷고, 도달 가능한 각 객체를 정확히 한 번 클론하고, 모든 위젯의 /P를 매핑된 목적지 페이지로 재지정하고, 클론된 위젯을 그 페이지의 /Annots 배열에 추가해야 합니다 — 그렇지 않으면 필드는 폼에는 존재하고 페이지에서는 보이지 않습니다. 필드와 그 위젯과 그것을 보여 주는 페이지 주석 사이의 차이를 쫓아 본 적이 있다면, 위젯 인덱스 대 주석 인덱스 노트가 정확히 그 분할을 다룹니다

델파이에서 PDFiumPas가 이식하는 한 PDF 폼 필드 뒤의 객체 그래프. 폼 사전, 필드, 위젯 주석, 목적지 페이지 주석 배열, 두 위젯이 공유하는 외관 스트림과 폰트, 그리고 순환을 닫는 부모 역참조
필드는 공유되는 순환 서브그래프입니다. 그래서 /Fields 배열을 문서 사이로 복사하면 모든 참조가 대롱거립니다

GraftPdfAcroForm은 여러분에게 무엇을 요구하는가

서로 다른 세 스트림과 명시적 페이지 매핑이 필요합니다. GraftPdfAcroForm은 별개의 TStream 인스턴스로 Source, Destination, Output을 받고, TPdfGraftPageMappings 배열, TPdfAcroFormGraftOptions 레코드, 선택적 TPdfCrossDocumentGraftMap, out TPdfAcroFormGraftReport를 받습니다. 예외를 던지는 대신 Boolean을 돌려주고, 실패 시 보고서가 ErrorMessage에 이유를 싣습니다. 페이지 매핑은 양쪽 모두 1 기반이고 추론되지 않습니다. 이식하려는 위젯을 실은 모든 소스 페이지가 그 안에 나타나야 합니다. 이식 맵에 nil을 넘기는 것도 정당합니다 — 함수가 호출 동안만 사적 맵을 만들고 놓아줍니다 — 그리고 TPdfAcroFormGraftOptions.DefaultCollisionPolicypagcpReject로, RenamePrefixImported_로, MaxObjects를 100000으로, MaxDepth를 128로, AllowSignedDestinationFalse로 줍니다. 마지막 셋은 예산이고, 막 걷기 시작할 객체 그래프가 여러분이 쓰지 않은 파일에서 나왔기 때문에 존재합니다

uses
  Classes, SysUtils, FPdfCompress;

var
  Source, Destination, Output: TMemoryStream;
  Options: TPdfAcroFormGraftOptions;
  Mappings: TPdfGraftPageMappings;
  Report: TPdfAcroFormGraftReport;
begin
  Source := TMemoryStream.Create;
  Destination := TMemoryStream.Create;
  Output := TMemoryStream.Create;
  try
    Source.LoadFromFile('claim-template-2025.pdf');
    Destination.LoadFromFile('claim-layout-2026.pdf');
    Source.Position := 0;
    Destination.Position := 0;

    Options := TPdfAcroFormGraftOptions.Default;

    SetLength(Mappings, 2);
    Mappings[0].SourcePageNumber := 1;
    Mappings[0].DestinationPageNumber := 1;
    Mappings[1].SourcePageNumber := 2;
    Mappings[1].DestinationPageNumber := 3;

    if GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, nil, Report) then
      Output.SaveToFile('claim-2026-with-fields.pdf')
    else
      raise Exception.Create(Report.ErrorMessage);
  finally
    Output.Free;
    Destination.Free;
    Source.Free;
  end;
end;

이식 맵은 공유 폰트를 두 번 클론하는 것을 어떻게 피하는가

TPdfCrossDocumentGraftMap은 소스-목적지 참조 테이블을 쥐는데, 키가 객체 번호와 세대를 둘 다 실고, 재귀 클로너는 내려가기 전에 그것을 조회합니다. 순환을 안전하게 만드는 것은 연산 순서입니다. 클로너는 목적지 객체 번호를 할당하고 매핑을 먼저 등록한 뒤, 소스 객체의 자식 참조를 걷습니다. 부모를 향해 되돌아 가리키는 자식을 만난 부모는 부모가 이미 등록되어 있음을 발견하고 재귀하는 대신 기존 목적지 참조를 돌려줍니다. 같은 조회가 여섯 위젯이 공유하는 폰트, 외관 스트림, 액션이 한 번 클론되고 여섯 번 참조되게 만듭니다. 맵은 소스 바이트의 SHA-256 해시로 소스 문서에 묶이고, SourceIdentity로 노출됩니다. 여러분이 넘긴 소스와 신원이 맞지 않는 맵을 GraftPdfAcroForm에 건네면, 이 파일에 결코 유효하지 않았던 참조를 재사용하는 대신 호출을 거절합니다. 페이지 매핑은 클론이 시작되기 전에 같은 맵에 심어집니다. 위젯의 /P가 목적지 페이지를 가리키게 되는 것이 정확히 그 경로입니다. 소스 페이지 객체가 이미 매핑된 목적지 페이지 객체로 해석되므로, 평범한 참조 재쓰기 패스가 특수 사례 없이 처리합니다

델파이의 PDFiumPas 문서 간 이식 맵은 각 소스 참조를 객체 번호와 세대로 키 잡고, 내려가기 전에 목적지 매핑을 등록해 부모 역참조를 종료시키며, 기존 항목을 돌려주어 공유 폰트가 한 번만 클론되게 한다
자식들을 걷기 전에 매핑을 등록하는 것이 순환 그래프를 안전하게 하고 공유 객체를 정확히 한 번 클론하게 합니다
uses
  Classes, SysUtils, FPdfCompress, FPdfSha256;

var
  GraftMap: TPdfCrossDocumentGraftMap;
  SourceBytes: TBytes;
  EntriesBefore: Integer;
begin
  SetLength(SourceBytes, Source.Size);
  Source.Position := 0;
  if Length(SourceBytes) > 0 then
    Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));

  GraftMap := TPdfCrossDocumentGraftMap.Create(
    AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
  try
    EntriesBefore := GraftMap.Count;
    Source.Position := 0;
    if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, GraftMap, Report) then
    begin
      // 이 호출이 추가한 항목은 롤백되었고,
      // 그 전에 등록된 것은 여전히 온전하다.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

그 롤백이 맵을 직접 소유하는 요점입니다. PDFiumPas는 호출자 제공 맵을 트랜잭션으로 다룹니다. 실패한 이식은 그 호출이 추가한 항목을 버리고 미리 존재하던 모든 매핑을 유지하므로, 한 번의 거절이 결코 쓰인 적 없는 객체에 대한 참조 캐시를 남기지 않습니다. 다만 목적지 문서마다 맵 하나를 유지하십시오 — 각 항목의 목적지 쪽은 그 특정 파일에서의 객체 번호이고, 다른 파일에서는 아무것도 의미하지 않습니다

필드 이름 충돌: 거절 또는 이름 바꾸기

완전 한정 필드 이름은 폼 안에서 유지되어야 하고, PDFiumPas는 부딪힐 때 여러분의 의도를 추측하지 않습니다. TPdfAcroFormCollisionPolicy는 정확히 두 답을 제공합니다. 기본인 pagcpReject 아래에서, 제목이 목적지에 이미 존재하는 첫 소스 필드가 오류로 전체 이식을 중단하고 출력 스트림을 비워 둡니다. pagcpRename 아래에서는 충돌하는 소스 필드가 RenamePrefix를 앞에 붙여 이름이 바뀌고 이식은 계속되며, Report.RenamedFieldCount가 그 일이 얼마나 자주 일어났는지 말해 줍니다

Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;

if GraftPdfAcroForm(Source, Destination, Output, Mappings,
  Options, nil, Report) then
begin
  WriteLn('source fields  : ', Report.SourceFieldCount);
  WriteLn('existing fields: ', Report.DestinationFieldCount);
  WriteLn('grafted fields : ', Report.GraftedFieldCount);
  WriteLn('renamed fields : ', Report.RenamedFieldCount);
  WriteLn('cloned objects : ', Report.GraftedObjectCount);
  WriteLn('reused objects : ', Report.ReusedObjectCount);
  WriteLn('mapped pages   : ', Report.MappedPageCount);
  WriteLn('output bytes   : ', Report.OutputByteCount);
end
else
  WriteLn('graft refused  : ', Report.ErrorMessage);

이름 바꾸기는 공짜가 아니고, 오류를 없애려고 손을 뻗는 대신 일부러 결정해야 합니다. 이름 바뀐 필드는 다른 필드입니다. 이름으로 그것을 부르는 목적지의 JavaScript, 사람이 옛 이름으로 쓴 /CO의 계산 항목, 필드 이름을 키로 삼는 모든 하류 소비자가 접두사를 알아야 합니다. 두 문서가 진짜 같은 필드를 기술한다면, 정직한 고침은 흔히 이식 시점이 아니라 상류에서 이름을 조율하는 것입니다. 이식이 착지하면 병합된 폼을 걸며 실제로 무엇을 받았는지 확인하는 것이 자연스러운 다음 단계고, PDFiumPas의 폼 필드 내비게이션이 그 순회를 다룹니다

이식이 일부러 fail closed 하는 곳

모든 애매한 조건은 오류이지 최선 노력 결과가 결코 아닙니다. 실전에서 놀라게 하기 전에 이해할 가치가 있는 설계 결정입니다. GraftPdfAcroForm은 이 목록에 걸리면 False를 돌려주고, 출력 스트림을 리셋하고, 이유를 보고합니다

  • 소스 폼이 /XFA 항목을 실고 있습니다 — XFA 패킷은 병렬 폼 모델이고 AcroForm 필드 사전으로 환원될 수 없습니다
  • 위젯이 페이지 매핑에 항목 없는 소스 페이지에 살면, 그렇지 않았다면 조용히 필드를 떨어뜨리거나 잘못된 페이지에 붙였을 것입니다
  • 페이지 매핑이 범위를 벗어났거나, 두 매핑이 같은 소스 또는 목적지 페이지를 재사용합니다
  • 두 폼이 기본 리소스 사전 /DR을 정의합니다. 두 리소스 이름 공간을 병합하면 기존 이름을 다른 폰트로 다시 가리킬 위험이 있기 때문입니다
  • 객체 그래프가 MaxObjects를 넘거나 재귀가 MaxDepth를 넘습니다
  • 목적지가 서명을 담고 있고 AllowSignedDestinationFalse입니다
  • 제공된 이식 맵이 다른 소스 문서에 속하거나, 소스 참조가 대롱거립니다

쓰기 경로도 똑같이 보수적입니다. PDFiumPas는 결과를 목적지에 덧붙이는 희소 증분 리비전으로 발행한 뒤, 쓰인 출력을 다시 실체화하고 그 폼을 다시 읽습니다. 결과의 필드 개수가 목적지 원래 필드 개수 더하기 소스의 것과 같지 않으면, 전체 이식이 거절되고 출력은 비워집니다. 부분적으로 이식된 파일은 결코 받지 않습니다. 그 정책의 비용은 진짜입니다 — /DR 충돌이나 서명된 목적지는 여러분을 완전히 멈추고, 병합된 근사를 받아들이기보다 직접 해결해야 합니다 — 하지만 대안은 잘 열리고 잘못 계산하는 폼입니다

델파이에서 PDFiumPas GraftPdfAcroForm이 fail closed 하는 방법. 쓰인 리비전은 다시 읽히고 필드 개수가 검증되며, XFA나 매핑 없는 페이지 같은 애매한 조건은 호출을 거절하고, 거절은 그 호출이 추가한 맵 항목만 버린다
검증된 쓰기 경로와 트랜잭션 맵이 거절된 이식이 결코 부분 병합 파일을 남기지 않는 이유입니다

이식이 잘못된 도구일 때

이식은 구조를 옮기므로, 빠진 것이 구조일 때 쓰십시오. 두 문서가 이미 같은 필드 세트를 실고 있고 값과 주석만 옮기면 된다면, XFDF 폼 데이터 문서의 내보내기·가져오기 경로가 더 가볍고 표준이고 되돌릴 수 있습니다. 목적지에 필드가 전혀 없거나 다른 세트를 갖고, 위젯, 외관 스트림, 액션, 계산 순서가 온전히 건너와야 할 때 GraftPdfAcroForm에 손을 뻗으십시오. 신원에 관한 마지막 실용 노트. 이식 맵은 객체 번호 더하기 세대로 키 잡히고 소스 바이트의 SHA-256에 묶이므로, 실행 사이에 소스를 다시 저장하거나 최적화하면 다른 신원과 더 이상 적용되지 않는 맵이 나옵니다. 이식할 소스를 스냅샷해 배치 동안 안정하게 유지하십시오. 밤마다 돌리는 작업이 마음대로 재작성하는 것이 아니라 입력 아티팩트로 다루십시오

GraftPdfAcroForm, TPdfCrossDocumentGraftMap과 주변의 스트림 수준 PDF 툴킷은 Delphi, C++Builder, Lazarus용 PDFiumPas Delphi PDFium Component에 실려 나가고, 제품 페이지가 이식 옵션, 보고서 필드, 문서 편집 표면의 나머지에 대한 전체 API 참조를 싣습니다