기술 문서

재현 가능한 빌드를 위한 Delphi 결정론적 PDF ID

같은 템플릿과 같은 데이터로 청구서를 두 번 생성한 뒤 두 PDF를 diff하면 두 곳이 다릅니다. 트레일러 /ID 배열의 16진수 문자열과 문서 정보 딕셔너리의 ModDate 문자열이며, 둘 다 시계에서 옵니다. losLab PDF Library에서는 SetDeterministicDocumentID(1)에 고정된 ModDate를 더하면 파일이 바이트 단위로 동일해집니다. 실행마다 XMP UUID나 첨부 파일이 없는 문서라면 말입니다

가장 먼저 시도하고 싶은 것은 날짜를 고정하고 다시 돌리는 것입니다. CreationDate와 ModDate는 이제 일치하지만 해시는 여전히 다릅니다. 이번에는 파일 맨 끝 근처 /ID [<…> <…>] 안의 32자리 16진수 문자열 둘만 다릅니다. 그 식별자는 메타데이터와 별도로 계산되고, 저장 시각에 의존하는 동안에는 파일 해시로 키잉된 빌드 캐시는 절대 적중하지 않고 콘텐츠 주소 저장소는 문서당 하나가 아니라 실행당 하나의 블롭을 쌓습니다

트레일러 ID 배열의 용도

트레일러 /ID는 파일을 식별하지 않습니다. 콘텐츠의 체크섬이 아닙니다. ISO 32000-1 §14.4는 이를 두 바이트 문자열의 배열로 정의합니다. 첫 번째는 문서가 만들어질 때 배정되어 이후 모든 편집에서 살아남도록 의도된 영구 식별자입니다. 두 번째는 작성기가 파일을 수정할 때마다 새로 쓰는 변경 식별자입니다. 문서 관리 시스템은 이 쌍을 비교해 두 파일이 한 문서의 리비전인지, 무관한 두 문서인지 판단합니다. §7.5.5의 표 15는 트레일러가 /Encrypt를 갖고 있을 때 이 엔트리를 필수로, 그 외에는 선택으로 둡니다. §14.4는 생략하면 파일 신원에 의존하는 워크플로우의 길을 막을 수 있다고도 적습니다

스펙은 값을 계산하는 방법을 말하지 않습니다. 현재 시각, 파일 경로, 파일 크기, 정보 딕셔너리의 값 같은 것들 위의 MD5 다이제스트를 제안할 뿐이고, 결과를 유일하게 만드는 재료는 시계입니다. 유일성은 신원 표지에 바로 원하는 것이자 재현성을 부수는 바로 그것입니다. 기본값에서 losLab PDF Library는 그 제안을 가장 단순한 형태로 따릅니다. 현재 타임스탬프의 MD5이므로, 한 초 간격으로 만들어진 두 문서는 다른 모든 바이트가 일치해도 다른 영구 식별자를 얻습니다

동일한 Delphi 빌드 실행에서 기본 벽시계 기반 PDF 트레일러 ID가 서로 달라지는 경우와 바이트 단위로 동일한 결정론적 출력 비교 다이어그램
기본 트레일러 id는 벽시계 시각을 파일 식별자에 반영하는 반면, 결정론적 모드는 안정적인 시드에서 이를 도출합니다

재현 가능한 식별자 켜기

결정론 모드는 문서별 스위치이며 기본값은 꺼져 있으므로, 요청하기 전까지 기존 출력은 바뀌지 않습니다. SetDeterministicDocumentID는 0이나 1을 받아 값이 받아들여지면 1, 그 외에는 0을 반환하고, GetDeterministicDocumentID가 현재 설정을 보고합니다. SetDocumentIDSeed는 명시적 시드 문자열을 공급하며, 시드가 있으면 식별자는 그 문자열의 MD5로 배열의 양쪽 요소에 기록됩니다. GetDocumentFileID는 저장 뒤 /ID[0]을 읽어 주므로 로깅이나 테스트 단언에 유용합니다

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // Create 직후
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // 매 실행마다 동일
  finally
    Lib.Free;
  end;
end;

식별자는 저장 시점에 계산됩니다. 문서 빌드 후반에 스위치를 켜도 동작하고, 바뀐 시드는 다음 풀 저장에 반영됩니다. 시드 A로 저장하고 시드 B로 다시 저장하면 두 출력은 다른 식별자를 갖고, 시드 A로 돌아가면 원래 값이 돌아옵니다

그래도 스위치는 객체를 만든 직후에 켜세요. 폰트, 이미지, 그래픽 상태, 기타 페이지 리소스는 페이지 리소스 딕셔너리에서 이름을 받는데, 결정론 모드가 켜져 있는 동안 그 이름들은 카운터에서 옵니다. 스위치 전에 추가된 것은 임의 생성 이름을 유지하므로, 식별자는 안정적이되 콘텐츠 스트림의 몇 바이트가 실행마다 바뀝니다

시드를 설정하지 않으면 식별자는 왜 여전히 바뀌는가?

정보 딕셔너리가 해시의 일부이기 때문입니다. 시드를 비워 두거나 SetDocumentIDSeed에 빈 문자열을 넘겨 지우면, losLab PDF Library는 파일 자신으로부터 식별자를 유도합니다. 직렬화된 본문을 쓰는 동안 SHA-256에 흘려 그 다이제스트의 첫 16바이트를 /ID 양쪽 요소에 씁니다. 페이지 콘텐츠가 바뀌면 식별자가 바뀌는데, 그것이 취지입니다. 함정은 CreationDate와 ModDate도 그 본문 안에 산다는 것이고, 호출자가 설정하지 않으면 저장 루틴이 ModDate에 현재 시각을 찍습니다

PDF Library for Delphi 결정 흐름: SetDocumentIDSeed가 결정론적 PDF id에 사용되는 파생 문서 상태 시드보다 우선함
명시적 시드는 다른 모든 것을 재정의하며, 파생 경로는 CreationDate와 ModDate를 포함한 info 딕셔너리 항목을 해시합니다
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // 저장된 바이트에서 /ID 유도
// 본문 해시는 Info 딕셔너리를 커버하므로 날짜도 빌드 입력이어야 합니다.
// 키 8은 저장이 ModDate를 찍는 것도 막습니다.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

키 8은 여기서 두 역할을 하며, 사람들이 가장 자주 빼먹는 줄입니다. SetInformation으로 ModDate를 설정하면 그 날짜가 호출자 공급으로 표시되고, 그 뒤로 SaveToFile과 SaveToString은 손대지 않습니다. 이것이 없으면 매 저장이 새 타임스탬프를 쓰고, 해시된 본문이 바뀌고, 콘텐츠 유도 식별자가 따라서 바뀝니다. 날짜는 원본 레코드나 고정된 기준점에서 가져오세요. 재현 가능 빌드에 Now가 설 자리는 없습니다

몇 가지는 의도적으로 해시에서 제외됩니다. 임의 생성 리소스 이름과 XMP xmpMM:InstanceID 엔트리의 UUID는 해싱 전에 정규화되므로, 그 토큰들만 다른 두 실행은 같은 /ID를 받습니다. 그 자리들의 파일 바이트는 여전히 다르므로, 실행마다 새 인스턴스 UUID를 갖는 PDF/A 스타일 문서는 재현 가능한 식별자를 얻지만 바이트 동일 파일은 얻지 못합니다. 임베드된 첨부 파일은 또 하나의 드리프트 원천입니다. AddAssociatedFileFromString은 첨부 파일 자체의 /ModDate에 현재 시각을 찍고, 그 날짜는 다른 본문 바이트와 마찬가지로 해시됩니다

명시적 시드 아니면 콘텐츠 해시

빌드 산출물에는 시드를 비워 두는 편이 좋다고 생각합니다. 콘텐츠 유도 식별자는 바이트가 바뀔 때 정확히 바뀌므로, 그것으로 키잉한 캐시는 오래된 파일을 내 줄 일이 없고, 데이터와 동기화를 유지할 두 번째 키도 생기지 않습니다. 명시적 시드는 다른 일에 맞습니다. /ID[0]을 문서 신원으로 취급하고, 템플릿이 바뀐 뒤에도 같은 청구서의 재생산에서 같은 식별자를 기대하는 하류 아카이브가 그 예입니다. 그쪽이 §14.4의 의도에 더 잘 맞지만, 눈으로 봐도 다른 두 파일이 같은 식별자를 공유할 수 있다는 대가가 붙습니다

콘텐츠 경로의 한 결과는 의사 결정을 요합니다. 기존 PDF를 로드해 결정론 모드를 켠 채 풀 저장하면, 들어온 /ID는 버려지고 새 식별자는 쓰여지는 것에만 의존합니다. 트레일러 식별자만 다른 두 원본 파일은 같은 /ID로 나옵니다. 정규화하는 빌드 단계가 원하는 것은 이것입니다. 고객의 문서를 편집하고 그 신원이 살아남아야 한다면, 그 작업에서는 결정론 모드를 끄거나 증분으로 저장하세요

ID를 다시 쓰면 암호화된 PDF가 왜 깨지는가?

암호화된 문서에서 /ID[0]는 키 자재이기 때문입니다. ISO 32000-1 §7.6.3.3 알고리즘 2는 리비전 2부터 4의 표준 보안 핸들러 암호화 키 계산에 파일 식별자의 첫 요소를, 패딩된 비밀번호, /O 값, 허가 비트와 함께 투입합니다. 그 키가 리더가 열 때 검사하는 /U 문자열을 만듭니다. 키는 Encrypt를 부를 때나 암호화 파일을 로드할 때, 어떤 저장보다 먼저 유도됩니다. 저장 동안 식별자를 갈아치우면 형식적으로 온전한 파일이 만들어지되 비밀번호 검사가 아무도, 당신조차도, 통과하지 못합니다

그래서 losLab PDF Library는 결정론 식별자를 암호화되지 않은 풀 저장에서만 새로 씁니다. 암호화된 문서는 자기가 이미 갖던 /ID를 유지하고 스위치는 그 경로에 효과가 없습니다. 사용자 비밀번호로 그런 파일을 다시 여는 것은 여느 때처럼 됩니다. 암호화 출력을 바이트 비교에 넣지도 마세요. §7.6.2의 AES 암호화는 모든 암호화 문자열과 스트림을 임의의 16바이트 초기화 벡터로 시작하므로, 같은 문서의 두 암호화는 절대 바이트 단위로 일치하지 않습니다. 거기서 회귀 검사가 필요하면 둘 다 복호화해서 돌아온 것을 비교하세요. 리비전 처리와 허가 비트는 PDF 암호화와 허가 감사 훑어보기에서 더 깊이 다룹니다

PDF Library for Delphi: 결정론적 PDF id의 가드레일 맵으로 전체 저장, 암호화 문서, 증분 업데이트, 암호화 복원 경로를 커버
암호화되지 않은 전체 저장만 id를 갱신할 수 있습니다. 암호화된 파일은 /ID[0]을 키 재료로 취급하고, 추가된 리비전은 연결 상태를 유지해야 하기 때문입니다

증분 저장은 원래 식별자를 유지합니다

증분 업데이트는 파일의 앞선 모든 바이트를 제자리에 두고 뒤에 새 리비전을 덧붙입니다. /ID[0]의 영구성이 소비자에게 덧붙은 리비전이 앞의 것들과 같은 문서임을 알려 줍니다. 그것을 다시 쓰면 파일 안의 기존 리비전들과 모순되고 서명 워크플로우를 떠받치는 기둥이 흔들리는데, 서명은 특정 리비전의 바이트 범위를 커버하기 때문입니다. append 모드는 결정론 모드에서 식별자를 절대 건드리지 않고, PDF 증분 업데이트와 스트림 append 글이 기술하는 동작을 그대로 유지합니다

출력을 시간이 지나도 재현 가능하게 유지하기

생성기에 가해지는 모든 이후 변경은 시계 값을 파일에 되살릴 기회입니다. 생성 시각을 찍는 워터마크, 파일을 첨부하는 코드 경로, 다른 저장 옵션입니다. 저장 옵션은 콘텐츠만큼 중요합니다. 객체를 압축 객체 스트림으로 묶으면 객체 스트림과 크로스 레퍼런스 스트림 노트가 설명하듯 고전적인 크로스 레퍼런스 테이블과 완전히 다른 파일 배치가 나오므로, 그 설정들은 파이프라인마다 고정하세요. 가장 싼 방어는 같은 문서를 메모리에서 두 번 만들어 바이트를 비교하는 테스트입니다

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// 독립된 두 빌드가 하나의 바이트 시퀀스를 내야 합니다
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

같은 하니스는 같은 원본 위의 직렬과 병렬 RecompressStreams 실행도 비교할 수 있습니다. 원본을 TPDFlib 인스턴스 둘에 로드하고 양쪽에서 결정론 ID를 켜고 ModDate를 고정한 다음, 한쪽은 SetOptimizationWorkers(1), 다른 쪽은 더 높은 워커 수로 두고 두 SaveToString 결과를 diff합니다. 시계가 두 파일에서 빠졌으므로 남는 차이는 재압축 자체에서 옵니다

재현 가능 트레일러 식별자와 메타데이터 통제, 증분 저장은 모두 Delphi와 C++Builder용 losLab PDF Library의 일부이며, 그 참조 문서는 여기서 언급한 암호화 옵션들도 나열합니다