HotPDF Delphi Component는 ReproducibleOutput 속성이 True일 때 저장할 때마다 바이트 단위로 동일한 PDF 출력을 만들어 냅니다. Info의 /CreationDate와 /ModDate를 고정된 날짜로 못 박고, 벽시계에서 얻는 문서 식별자를 시드 기반 또는 내용 기반 해시로 교체하며, AES 암호화 경로가 그렇지 않으면 뽑아낼 모든 난수 바이트를 상수로 대체하고, 직렬화하는 모든 딕셔너리를 정렬합니다. 이 플래그는 프로덕션 문서가 아니라 회귀 스위트와 빌드 산출물 비교를 위한 것이고, 그 경계를 두는 이유가 이 글의 흥미로운 부분입니다. 이 기능을 이끈 시나리오는 골든 파일 테스트입니다. 인보이스를 렌더링하고 PDF를 커밋한 다음 내일 빌드가 같은 바이트를 만들어 내는지 단언합니다. 절대 그렇게 되지 않습니다. 파일은 모든 뷰어에서 잘 열리고, 텍스트도 같고, 페이지 트리도 같은데 diff는 네다섯 군데에서 여전히 불이 들어옵니다. PDF 생성기를 바이트 수준 회귀 테스트에 넣어 보려 한 사람은 누구나 이 벽에 부딪혔고, 해법은 "타임스탬프를 걷어내는 것"이 아니라 라이터가 문서 자체가 아닌 무언가를 참조하는 모든 지점을 정확히 셈하는 것입니다
같은 PDF를 두 번 저장하면 왜 달라질까요?
같은 문서를 두 번 저장한 결과가 다른 이유는 HotPDF를 포함한 PDF 라이터가 페이지 내용과 아무 관련 없는 네 가지 엔트로피 원천을 참조하기 때문입니다. 벽시계, 문서 식별자, 암호학적 난수 생성기, 그리고 딕셔너리 항목의 메모리 순서입니다. 각각은 그 자체로는 정당합니다. ISO 32000-1이 그것들을 요구합니다. 다만 이들이 합쳐지면 파일이 담고 있는 내용이 아니라 언제 어디서 쓰였는지의 함수가 됩니다
- 시계. Info 딕셔너리는
/CreationDate와/ModDate(ISO 32000-1 §14.3.3, 표 317)를 시간대 접미사가 붙은D:YYYYMMDDHHmmSS문자열(§7.9.4)로 담고, XMP 패킷은 같은 시각을xmp:CreateDate와xmp:ModifyDate로 반복합니다. HotPDF는 둘 다FCreationDate에서 찍는데, 생성자가 이를Now로 초기화하므로 두 저장은 쓰인 초 단위에서 달라집니다 - 식별자. 트레일러
/ID배열(ISO 32000-1 §14.4)은 영구 식별자와 수정 식별자를 담습니다. HotPDF의 기본 방식은 첫 원소에 파일 이름과 현재 시각을 밀리초까지 함께 해시하고, 둘째 원소에는 그것에GetTickCount를 더해 해시합니다. 식별자 두 개, 실행할 때마다 새 값 두 개입니다 - 난수 바이트. 표준 보안은 식별자와 진짜 난수성에 의존합니다. AES-256에서는 파일 암호화 키, 검증 salt와 키 salt, 모든 CBC 초기화 벡터가 시스템 난수 원천에서 뽑힙니다(ISO 32000-2 §7.6.4.4.7은 무작위 salt를 요구합니다).
/U,/UE,/O,/OE가 모두 그 바이트들로 계산되므로, 암호화된 문서는 평문이 그대로여도 전체가 달라집니다. 예전 알고리즘들은 첫/ID원소를 키에 접어 넣기 때문에(ISO 32000-1 §7.6.3.3, §7.6.3.4), 식별자만 새로 나와도 파일 키가 다시 잡힙니다 - 순서. PDF 딕셔너리는 순서 없는 매핑이고, 메모리 리스트를 순회하는 라이터는 키를 삽입 순서대로 내보냅니다. 리소스 딕셔너리를 다른 순서로 만드는 코드 경로나, 다른 레이아웃에서 파싱된 로드 문서는 합법이지만 텍스트가 다른 파일을 만들어 냅니다
ReproducibleOutput은 무엇을 고정할까요?
BeginDoc 전이나 SaveLoadedDocument 전에 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 안에서 재현 가능 분기는 FCreationDate := EncodeDate(2026, 1, 1)을 대입하고, 문서 식별자를 파일 이름 더하기 시계 다이제스트 대신 MD5CalcString('HotPDF-reproducible-seed')로 시드합니다. 이 한 번의 대입이 Info 날짜 둘과 XMP 날짜 둘을 모두 커버합니다. 넷 다 같은 필드에서 렌더링되기 때문입니다. 파일이 마침내 기록될 때 BuildDocumentIdentifiers는 트레일러 식별자를 ComputeCanonicalDocumentIdentifier에 요청합니다. 이 함수는 객체 그래프 전체를 정규 순서로 내보내고, 찾아낸 모든 D: 날짜 문자열의 숫자를 0으로 만들어 타임스탬프가 해시를 통해 되새어 들어오지 못하게 하고, 그 결과의 MD5를 취합니다. /ID의 두 원소 모두 그 값을 받습니다. 같은 내용 기반 식별자는 로드된 문서를 BeginDoc을 거치지 않고 암호화할 때도 쓰이는데, LoadFromFile로 연 파일에 ActivateProtection을 적용하는 경우가 그렇습니다
난수 바이트가 가장 덜 뻔한 대체입니다. AES-256 키 루틴은 자기 난수 원천을 로컬 헬퍼로 감싸는데, 플래그가 켜져 있으면 32바이트 파일 암호화 키와 8바이트 salt마다 FillChar(P^, Count, $5A)를 호출합니다. AES-128과 AES-256 문자열·스트림 암호화기도 AESGenerateRandomIV에서 AESGenerateStaticIV로 바꿔 타는데, 이 함수는 슬롯 I의 초기화 벡터를 14 * (1 + I)로 채웁니다. 키와 salt, 벡터가 모두 고정되면 /U, /UE, /O, /OE와 모든 암호화된 스트림이 두 번째 실행에서도 동일하게 나옵니다. 마지막으로 SaveToStream은 재현 플래그가 설정되어 있으면 DeterministicDictionaryOrder를 켜고, 직렬화기는 각 딕셔너리를 키 이름의 원시 바이트로 삽입 정렬합니다. 짧은 접두사가 먼저이고, 동점이면 원래 인덱스가 기준입니다. 진단 라이터가 쓰는 것과 같은 순서이며, PDF를 손으로 편집하고 나중에 복구하는 글에서 설명합니다. 재현 플래그는 그 순서만 빌려 오고, 그 라이터의 평문 레이아웃 나머지는 가져오지 않습니다
고정된 날짜가 왜 여전히 벽시계를 흘렸을까요?
v2.752.2 수정이 있는 이유는 고정된 생성 날짜가 원래 생성자에서 정해졌는데, 생성자는 호출자가 아직 설정하지 않은 속성을 알 수 없기 때문입니다. 정상적인 호출 순서는 Create, 그다음 ReproducibleOutput := True, 그다음 BeginDoc입니다. 생성 시점에 FReproducibleOutput은 아직 False라서 FCreationDate가 Now를 받고 그대로 들고 있었습니다. 식별자와 난수 바이트는 제대로 고정되었기 때문에 두 파일은 거의 모든 곳에서 일치하고 정확히 날짜 문자열 두 개와 XMP 필드 두 개에서만 어긋났습니다. 대입을 BeginDoc의 재현 가능 분기로, 시드된 식별자 옆으로 옮긴 것이 그 결정을 속성이 최종값을 갖는 지점에 놓았습니다
이걸 놓친 회귀 테스트가 수정 자체보다 값집니다. 두 저장이 같은 벽시계 초 안에서 실행되면 같은 D: 문자열을 우연히 쓰게 되고, 바이트 비교는 더 느린 기계에서는 실패할 버그를 통과시킵니다. 수정된 테스트는 두 저장 사이에 1100 ms를 재워 PDF 타임스탬프가 반드시 초 경계를 넘게 하고, 평문과 AES-128, AES-256 출력에 대해 케이스를 돌리며 두 암호화 변형에는 실제 비밀번호를 쓰고, 두 버퍼를 CompareMem으로 비교하며 실패 시 첫 번째 다른 오프셋을 보고해서 diff가 파일 전체가 아니라 특정 객체를 가리키게 합니다. 바이트 비교는 결정성만 증명하고 그 밖에는 아무것도 증명하지 않으므로, 암호화된 출력을 사용자 비밀번호로 다시 로드해 페이지 수를 읽는 단언을 따로 두십시오. 파일을 안정적이면서 동시에 읽을 수 없게 만드는 변경이 초록색 diff를 믿고 빠져나가면 안 됩니다
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// 테스트 본문에서
A := SaveOnce(PathA);
TThread.Sleep(1100); // PDF 타임스탬프의 초를 강제로 다르게 만듭니다
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
재현 가능한 암호화 PDF는 여전히 안전할까요?
아닙니다. ReproducibleOutput으로 암호화된 문서는 어떤 의미에서도 보호되지 않고, 테스트 디렉터리를 벗어나는 것에는 플래그를 꺼야 합니다. AES-256 파일 암호화 키는 $5A 32바이트이고, salt는 $5A 8바이트이며, 초기화 벡터는 공개된 산술 패턴을 따릅니다. 비밀번호가 여전히 /UE와 /OE 래퍼를 막고 있지만 감싸인 키가 상수이므로, 그 상수를 아는 사람은 비밀번호 없이 모든 콘텐츠 스트림을 복호화할 수 있습니다. salt가 고정되면 ISO 32000-2 §7.6.4.4.7이 기대는 문서별 고유성도 사라집니다. 같은 비밀번호가 파일마다 같은 /U 문자열을 만들지 않게 해 주는 성질입니다. 난수 원천이 온전할 때 암호화 속성이 무엇을 약속하는지는 AES-256 설정 글을 읽어 보십시오. 재현 플래그 아래에서는 그 약속들이 정지됩니다
식별자 쪽 절충은 더 미묘합니다. ISO 32000-1 §14.4는 두 번째 /ID 원소가 수정할 때마다 바뀌어서 도구가 갱신된 파일과 그 조상을 구분할 수 있기를 의도하는데, 재현 가능한 저장은 두 슬롯에 같은 값을 씁니다. 그 값이 정규 객체 그래프의 해시이므로 내용이 다른 두 문서는 여전히 다른 식별자를 받고, 이는 상수보다 낫습니다. 하지만 BeginDoc이 키 유도에 쓰는 시드는 모든 기계의 모든 문서에 대해 같은 문자열이고, /ID를 키로 삼아 파일을 구분하는 리더, 예를 들어 주석 캐시나 폼 데이터 사이드카는 우연히 같은 해시가 나온 모든 재현 파일을 한 덩어리로 뭉갤 것입니다
플래그가 다루지 않는 것은?
ReproducibleOutput은 라이터가 스스로 만들어 내는 엔트로피를 제거합니다. 환경을 통해, 또는 라이터가 통제하지 않는 코드 경로를 통해 들어오는 엔트로피는 제거할 수 없고, 그중 셋은 쉽게 걸려 넘어집니다
- 시간대 접미사.
_DateTimeToPdfDate가 로컬 UTC 오프셋을 덧붙이므로, 한 빌드 에이전트의D:20260101000000+08'00'과 다른 에이전트의D:20260101000000-05'00'은 같은 고정 날짜에 대해 다른 바이트입니다. 재현성은 한 기계에서의 반복 실행, 또는 같은 시간대를 쓰는 기계들 사이에서 성립합니다. 골든 파일이 다른 곳으로 간다면 에이전트의 시간대를 고정하십시오 - 증분 업데이트.
SaveIncrementalUpdate는 대상 경로와GetTickCount, 현재 시각으로 수정 식별자를 계산하며 재현 가능 분기가 없습니다. 증분 섹션은 정의상 새로운 수정이기 때문입니다. 덧붙인 델타가 아니라 전체 재작성을 비교하십시오 - 통과 지름길.
SaveLoadedDocument는 보통 수정되지 않고 암호화되지 않은 원본 파일을 다시 직렬화하지 않고 바이트 단위로 복사합니다. 재현 플래그는 그 지름길을 끄고 전체 재작성을 강제해서 순서와 식별자 규칙이 적용되게 합니다. 즉 로드된 파일의 재현 가능한 저장은 기본보다 느리고 입력의 복사본이 결코 아닙니다. 원본이 아니라 이전 재현 저장과 비교하십시오
같은 릴리스에서 나온 교훈이 하나 더 있는데, 통과하는 검사가 무엇을 증명하고 무엇을 증명하지 않는지에 관한 것입니다. CharProcs.DeleteValue('A')를 호출하는 PDF/X-6 테스트 픽스처가 있었는데, 직접 들고 있던 글리프 스트림을 해제한 뒤 같은 포인터를 다시 삽입했고, 별도로 직접 ExtGState 객체 하나를 리소스 딕셔너리와 패턴 양쪽에 넘겼습니다. 적합성 검증기는 그 use-after-free와 이중 소유에서 간헐적으로 통과했습니다. 해제된 메모리에 우연히 남아 있는 값을 읽고 있었기 때문입니다. 구조 검사가 깜빡이면 검증기를 보기 전에 테스트 입력의 소유권을 보십시오. 재현 가능한 출력은 그 규율을 더 싸게 만듭니다. 두 저장이 바이트 단위로 동일해지면 깜빡임의 남은 원인은 객체 그래프 자체뿐이고, 카탈로그부터 내려가는 구조적 diff가 그것을 찾아낼 것입니다
여기서 설명한 ReproducibleOutput, DeterministicDictionaryOrder, 암호화 속성은 Delphi와 C++Builder용 표준 HotPDF Delphi Component에 들어 있고, 같은 플래그가 라이브러리 자체의 회귀 코퍼스를 구동합니다. 테스트 스위트에서 얻는 동작이 곧 이 컴포넌트가 테스트되는 동작입니다