기술 문서

Delphi의 재현 가능한 PDF 출력: 바이트 단위로 동일한 저장

같은 인보이스 코드가 1초 간격으로 쓴 PDF 두 개가 같은 파일인 일은 거의 없습니다. 바이너리 비교는 먼저 Info 딕셔너리 안에서 넘어지는데 /CreationDate가 1초 움직였고, 다음은 XMP 패킷, 그다음은 트레일러 /ID입니다. AES-256을 켜면 거의 모든 스트림에서 비교가 무너지는데 파일 암호화 키 자체가 새로 만들어졌기 때문입니다. 비교 전에 날짜를 가리는 것으로 처음 두 차이는 메워지지만 나머지는 그렇지 않습니다. 골든 파일 테스트는 작성기가 문서 밖에서 가져오는 모든 값을 읽는 시점에 고정할 때 비로소 신뢰할 수 있어집니다

문서에서 온 적 없는 네 가지 값

HotPDF Delphi Component는 다른 모든 PDF 작성기와 마찬가지로 네 종류의 값을 페이지 콘텐츠 밖에서 채웁니다. 시계, 문서 식별자, 암호학적 난수원, 딕셔너리 항목의 메모리상 순서입니다. ISO 32000-1은 각각 존재 이유가 있습니다

  • 시계. Info 딕셔너리는 /CreationDate와 /ModDate(ISO 32000-1 §14.3.3, 표 317)를 시간대 접미사가 붙은 D:YYYYMMDDHHmmSS 문자열로(§7.9.4) 나르고, XMP 패킷은 같은 순간을 xmp:CreateDate와 xmp:ModifyDate로 반복합니다. 이들은 모두 컴포넌트가 생성 시 Now에서 가져온 하나의 생성 타임스탬프에서 렌더링됩니다
  • 식별자. 트레일러 /ID 배열(§14.4)은 영구 식별자와 수정 식별자를 담습니다. 기본값으로 첫째는 파일 이름에 현재 시각을 밀리초까지 합쳐 MD5한 값이고, 둘째는 그 값에 시스템 틱 카운트를 더해 다시 해시한 값입니다
  • 난수 바이트. AES-256 문서에는 32바이트 파일 암호화 키, /U를 위한 8바이트 검증 솔트와 8바이트 키 솔트, /O를 위한 또 한 쌍, /Perms 안의 필러 4바이트, 그리고 암호화된 문자열과 스트림마다 앞에 놓이는 16바이트 초기화 벡터가 필요합니다. ISO 32000-2 §7.6.4.4.7은 솔트에 난수를 요구합니다. RC4와 AES-128은 키를 암호와 첫 /ID 요소에서 유도하므로(ISO 32000-1 §7.6.3.3) 새 식별자 하나만으로도 파일 전체가 다시 키잉됩니다
  • 순서. PDF 딕셔너리는 순서 없는 매핑입니다(§7.3.7). 메모리 리스트를 걷는 작성기는 코드가 우연히 추가한 순서대로 키를 내보내므로, 같은 리소스 딕셔너리를 다른 순서로 만드는 두 코드 경로는 합법적이지만 텍스트로는 다른 파일을 낳습니다
하나의 문서에 대한 HotPDF 저장 두 번이 다르게 나오는 네 가지 엔트로피원: Now에서 가져온 생성 타임스탬프가 D: 날짜와 XMP 패킷을 먹이고, 트레일러 /ID는 파일 이름과 시계, 틱 카운트를 해시하며, AES는 시스템 난수원에서 키 재료를 끌고, 딕셔너리는 메모리 삽입 순서로 직렬화됩니다
각 원은 저마다 정당하고 ISO 32000-1도 그 자리를 요구하지만, 함께 놓이면 파일은 무엇을 담는지가 아니라 언제 어디서 쓰였는지의 함수가 됩니다

나머지는 이미 입력에서 따라 나옵니다. 콘텐츠 스트림, 압축 이미지 데이터, 크로스레퍼런스 오프셋은 같은 드로잉 호출에 대해 반복됩니다. 겉보기에 무작위인 유일한 필드조차 아닙니다. 임베드 폰트 서브셋의 여섯 글자 접두사(ISO 32000-1 §9.6.4, ABCDEF+Arial의 ABCDEF+ 부분)는 서브셋이 간직한 글리프 집합에서 계산되므로 같은 텍스트는 실행마다 같은 접두사를 냅니다. 소음은 메타데이터와 보안 계층에 있고, 그래서 속성 하나로 제거됩니다

ReproducibleOutput이 고정하는 것

BeginDoc 전에 ReproducibleOutput := True로 두면 네 원이 각각 고정값으로 대체됩니다. 시계나 난수 생성기에 손을 뻗었을 코드 경로 그 자체에서요. 뒤따르는 청소 패스는 없습니다. 이 속성의 기본값은 False이고, 라이브러리 어디도 대신 켜 주지 않습니다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // BeginDoc 전에 설정해야 합니다
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

플래그가 켜지면 BeginDoc은 생성 타임스탬프를 현지 시각 2026년 1월 1일 00시로 두고, Info 날짜와 XMP 날짜가 모두 그 하나의 값에서 렌더링되므로 대입 한 번이 전부를 커버합니다. 같은 분기가 내부 문서 식별자를 파일 이름과 시계 대신 상수 문자열로 심습니다. 파일을 쓸 때 트레일러 /ID는 모든 D: 날짜 문자열의 숫자를 먼저 0으로 만든 정규화된 객체 그래프 내보내기에 대한 MD5로 계산되어, 타임스탬프가 해시를 통해 몰래 끼어들 수 없습니다. /ID의 두 요소가 그 값을 받습니다. BeginDoc을 한 번도 부르지 않고 로드된 문서를 암호화하는 경우(LoadFromFile 뒤에 ActivateProtection 설정)에도 같은 콘텐츠 유도 식별자를 받습니다

난수 바이트는 상수로 대체됩니다. AES-256 파일 키, 네 솔트 전부, /Perms 필러는 $5A로 채워지고, 모든 CBC 초기화 벡터는 AES-128 문자열과 스트림은 물론 AES-256까지 바이트 I가 14 * (1 + I) 패턴이 됩니다. 그 패턴은 의도적으로 0이 아닙니다. 올 0 IV는 CBC의 교과서적 약점인데 회귀 스위트가 그걸 정규화할 이유는 없습니다. 키와 솔트와 벡터가 고정되면 두 번째 실행에서 /U, /UE, /O, /OE, /Perms, 암호화된 모든 스트림이 똑같이 나옵니다

마지막으로 SaveToStream은 재현 가능 플래그가 켜져 있으면 DeterministicDictionaryOrder를 켭니다. 선형화, 오브젝트 스트림, 클래식 레이아웃 사이에서 고르기 전에 하므로, LinearizeOutput이나 UseObjectStreams를 쓴 출력도 정렬됩니다. 키는 날 바이트 순으로 정렬되고, 다른 키의 접두사인 키는 먼저 오며, 원래 위치가 동률을 깹니다. 진단 작성기가 쓰는 바로 그 순서로, PDF 손편집과 사후 복구 글에 설명되어 있습니다. 재현 가능 플래그는 그 순서만 빌려 오고 그 작성기의 평문 레이아웃은 가져오지 않습니다. DeterministicDictionaryOrder만 켜는 것은 반쪽짜리 처방입니다. 키는 맞춰지지만 Info 날짜와 두 트레일러 /ID 문자열은 여전히 시계를 나르므로, 두 저장은 같은 네 곳에서 계속 어긋납니다

HotPDF에서 ReproducibleOutput이 고정하는 것: 생성 날짜는 2026년 1월 1일이 되고, 트레일러 식별자는 D: 숫자를 0으로 만든 정규화 객체 그래프의 MD5가 되며, AES 키와 솔트는 $5A 바이트로 채워지고 각 IV 바이트는 14 × (1 + i)를 따르고, DeterministicDictionaryOrder가 모든 딕셔너리를 정렬합니다
대체는 시계나 난수 생성기에 손을 뻗었을 코드 경로 그 자체에서 일어나므로 별도의 청소 패스가 필요 없고 두 /ID 요소가 같은 콘텐츠 유도 값을 받습니다

요행으로는 통과할 수 없는 테스트

날짜가 BeginDoc에서 고정되는 이유는 생성자가 호출자가 속성을 설정할 기회를 갖기 전에 도는 것과 관련이 있습니다. 정상 순서는 Create, 그다음 ReproducibleOutput := True, 그다음 BeginDoc이고, 최종값을 가진 플래그를 보는 것은 셋 중 마지막뿐입니다. 이 순서는 테스트 설계에 직접적인 함의가 있습니다. 같은 벽시계 초 안에 끝난 두 저장은 우연히 같은 D: 문자열을 쓰므로, 빠른 테스트는 날짜가 고정됐든 아니든 통과하고 몇 달 뒤 느린 빌드 에이전트에서 실패합니다

처방은 값쌉니다. 두 저장 사이에 1100 ms를 기다려 타임스탬프가 초 경계를 넘는 것을 보장하고, 평문과 AES-128과 AES-256 출력에 대해 암호화 변형에는 실제 암호를 넣어 케이스를 돌리며, 단순한 참·거짓 대신 처음 어긋나는 바이트 오프셋을 보고합니다. 오프셋은 한 객체를 가리킵니다. 불리언은 파일 전체를 가리킬 뿐입니다

uses
  System.SysUtils, System.IOUtils, HPDFDoc;

function SaveOnce(const Target: string; Encrypt: Boolean;
  KeyType: THPDFKeyType): TBytes;
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.VerifySavedOutput := True;   // EndDoc 직후의 엄격한 리로드
    if Encrypt then
    begin
      Pdf.OwnerPassword := 'owner';
      Pdf.UserPassword := 'user';
      Pdf.CryptKeyLength := KeyType; // aes128 또는 aes256
      Pdf.ActivateProtection := True;
    end;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Result := TFile.ReadAllBytes(Target);
end;

// 여러분의 헬퍼: 같으면 -1, 아니면 처음 어긋나는 오프셋
function FirstDifference(const A, B: TBytes): Integer;
var
  I, N: Integer;
begin
  N := Length(A);
  if Length(B) < N then
    N := Length(B);
  for I := 0 to N - 1 do
    if A[I] <> B[I] then
      Exit(I);
  if Length(A) <> Length(B) then
    Result := N
  else
    Result := -1;
end;

procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
  A, B: TBytes;
  Offset: Integer;
begin
  A := SaveOnce('run-a.pdf', Encrypt, KeyType);
  Sleep(1100);                     // 타임스탬프 초를 다르게 만들기
  B := SaveOnce('run-b.pdf', Encrypt, KeyType);
  Offset := FirstDifference(A, B);
  if Offset >= 0 then
    raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;

CheckReproducible(False, aes128), CheckReproducible(True, aes128), CheckReproducible(True, aes256)를 호출하세요. 평문 실행에서는 키 타입이 무시됩니다. VerifySavedOutput은 이 테스트에서 제 몫을 합니다. 켜 두면 EndDoc은 방금 쓴 파일을 크로스레퍼런스 복구를 끈 새 THotPDF 인스턴스로, 보호된 문서면 사용자 암호를 써서 다시 읽고 실패하면 예외를 던집니다. 출력을 안정하게 만드는 동시에 읽을 수 없게 만드는 변경은 초록불 디프를 그대로 통과할 것입니다. 리로드가 그 변경을 막습니다

재현 가능한 암호화 PDF는 여전히 안전한가?

아니요. ReproducibleOutput 아래에서 암호화된 문서에는 실질적인 보호가 없고, 테스트 디렉터리를 나가는 모든 것에 대해 이 플래그는 꺼져 있어야 합니다. AES-256 파일 키는 $5A 32바이트입니다. 암호는 여전히 /UE와 /OE 포장을 잠그지만, 그 포장이 감싼 것은 공개된 상수이므로 그 상수를 아는 사람은 암호 없이 모든 콘텐츠 스트림을 복호화할 수 있습니다. 고정 솔트는 같은 암호가 두 파일에서 같은 /U 문자열을 내지 않도록 하는 ISO 32000-2 §7.6.4.4.7의 파일별 유일성도 제거합니다. AES-256 설정 글은 난수원이 온전할 때 암호화 속성들이 무엇을 주는지 설명하는데, 재현 가능 플래그 아래에서는 그 어느 것도 적용되지 않습니다. 이 속성은 프로덕션 코드 경로가 닿을 수 있는 설정이 아니라 테스트 전용 구성값 뒤에 가두세요

AES-GCM은 다르게 다뤄집니다. CryptKeyLength := aesgcm(PDF 2.0 전용)이면 컴포넌트는 8바이트 논스 접두사를 ReproducibleOutput이 뭐라 하든 시스템 난수원에서 끌고, 암호화된 문자열이나 스트림마다 위로 셉니다. 그것이 올바른 동작입니다. GCM은 같은 키 아래에서 논스가 반복되면 크게 실패합니다. 두 암호문은 평문의 XOR을 누설하고 공격자는 인증 태그를 위조할 만큼 회복할 수 있습니다. 고정 키에 고정 논스라면 스위트가 내놓는 모든 파일에서 그 반복이 확정됩니다. 그래서 암호화된 골든 파일은 CBC 모드로 도는 aes128이나 aes256을 씁니다

식별자는 더 순한 트레이드오프입니다. ISO 32000-1 §14.4는 두 번째 /ID 요소가 수정마다 바뀌길 의도하는데, 재현 가능 저장은 두 슬롯에 같은 값을 씁니다. 그 값이 객체 그래프를 해시하므로 다른 콘텐츠는 여전히 다른 식별자를 받습니다. /ID로 캐시를 키잉하는 리더, 가령 주석 저장소나 폼 데이터 사이드카는 같은 콘텐츠의 재현 가능 저장 두 개를 여전히 한 파일로 칩니다. 테스트 스위트에서는 그게 정확히 원하는 바입니다

작성기 밖에 머무는 입력들

ReproducibleOutput은 작성기가 스스로 도입하는 엔트로피를 커버합니다. 빌드 머신이나 정의상 새 시각을 기록하는 연산에서 오는 입력은 몇 가지 더 있습니다

HotPDF 재현 가능 저장이 어디서 멈추는가: PDF 날짜 문자열은 여전히 현지 UTC 오프셋을 나르므로 골든 파일은 시간대를 넘어 다르고, SaveIncrementalUpdate는 델타가 새 수정이므로 새 수정 식별자를 만들며, 패스스루 지름길은 꺼져서 로드된 파일은 항상 전면 재작성됩니다
재현성은 한 기계 위의 실행들 또는 시간대를 공유하는 기계들 사이에서 유지되며, 재현 가능 저장은 이전의 재현 가능 저장과 비교해야지 원본 입력과 비교해서는 안 됩니다
  • 시간대 접미사. 모든 PDF 날짜 문자열은 현지 UTC 오프셋으로 끝나므로 같은 고정 날짜가 한 빌드 에이전트에서는 D:20260101000000+08'00', 다른 에이전트에서는 D:20260101000000-05'00'으로 읽힙니다. 골든 파일은 한 기계 위의 실행들 또는 시간대를 공유하는 기계들 사이에서 유지됩니다. 파일이 사무실 사이를 오간다면 에이전트의 시간대를 고정하세요
  • 증분 업데이트. SaveIncrementalUpdate는 소스의 영구 식별자를 유지하고 이전 식별자, 대상 경로, 틱 카운트, 현재 시각으로 새 수정 식별자를 만듭니다. 덧붙는 구간은 정의상 새 수정입니다. 덧붙는 델타가 아니라 전면 재작성을 비교하세요
  • 패스스루 지름길. SaveLoadedDocument는 보통 수정되지 않은 암호화되지 않은 소스를 다시 직렬화하는 대신 바이트 단위로 복사합니다. 플래그 아래에서는 순서와 식별자 규칙이 적용되도록 파일이 항상 다시 쓰이는데, 저장은 느려지고 입력의 복사본은 결코 아닙니다. 이전의 재현 가능 저장과 비교하세요. 원본과는 절대로
  • 직접 넣는 타임스탬프. AddDocumentAttachment는 임베드 파일의 /Params /CreationDate와 /ModDate(ISO 32000-1 §7.11.4)를 호출 시각으로 찍고, 서명은 서명 시각을 /M에 기록합니다. 이 날짜들은 호출자가 요청한 사건을 기술하므로 플래그는 손대지 않습니다. 바이트 수준 케이스에서는 첨부와 서명을 빼거나 비교 시 그 문자열을 가리세요
  • 설치된 폰트. TrueType 서브셋은 테스트를 도는 기계에 설치된 폰트 파일에서 잘립니다. Arial 릴리스가 다른 두 에이전트는 같은 텍스트에 대해 다른 글리프 프로그램을 임베드하고 서브셋 접두사도 따라 바뀔 수 있습니다. 폰트 파일을 빌드 이미지에 실으세요

로드된 문서도 같은 규칙을 따릅니다. 로드 전에 속성을 설정하고, 편집을 하고, SaveLoadedDocument로 저장하세요. 출력은 다음 실행의 기준으로 커밋할 수 있는 정규화된 재작성물입니다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.ReproducibleOutput := True;   // 로드 전에 설정
    if Pdf.LoadFromFile('incoming.pdf') <= 0 then
      raise Exception.Create('incoming.pdf did not load');
    // ... 테스트 대상 편집 ...
    Pdf.SaveLoadedDocument('normalized.pdf');  // 전면 재작성, 정렬된 키
  finally
    Pdf.Free;
  end;
end;

바이트 단위로 동일한 저장은 깜빡거리는 적합성 검사도 궁지로 몰아넣습니다. 작성기가 안정되면 한 실행에서 통과하고 다음에서 실패하는 검사는 테스트가 만드는 객체 그래프에 반응하는 것입니다. 가령 하나의 직접 딕셔너리를 두 부모가 받는 경우죠. 검증기보다 테스트 입력을 먼저 들여다보세요. 카탈로그부터 내려가는 구조적 디프가 두 실행 사이에 어느 객체가 움직였는지 보여 줍니다

위에서 쓴 ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput, 암호화 속성들은 HotPDF Delphi Component의 일부이며, Delphi와 C++Builder 양쪽용으로 제공됩니다